UNPKG

appium-xcuitest-driver

Version:

Appium driver for iOS using XCUITest for backend

328 lines (305 loc) 11.5 kB
import _ from 'lodash'; import B from 'bluebird'; import {DEFAULT_WS_PATHNAME_PREFIX} from 'appium/driver'; import {IOSCrashLog} from '../device/log/ios-crash-log'; import {IOSSimulatorLog} from '../device/log/ios-simulator-log'; import {IOSDeviceLog} from '../device/log/ios-device-log'; import WebSocket from 'ws'; import { SafariConsoleLog } from '../device/log/safari-console-log'; import { SafariNetworkLog } from '../device/log/safari-network-log'; import { toLogEntry } from '../device/log/helpers'; import { NATIVE_WIN } from '../utils'; import { BIDI_EVENT_NAME } from './bidi/constants'; import { makeLogEntryAddedEvent } from './bidi/models'; import type {XCUITestDriver} from '../driver'; import type {LogEntry, LogListener} from './types'; import type {LogDefRecord, AppiumServer, WSServer} from '@appium/types'; import type {Simulator} from 'appium-ios-simulator'; import type {EventEmitter} from 'node:events'; /** * Determines the websocket endpoint based on the `sessionId` */ const WEBSOCKET_ENDPOINT = (sessionId: string): string => `${DEFAULT_WS_PATHNAME_PREFIX}/session/${sessionId}/appium/device/syslog`; const COLOR_CODE_PATTERN = /\u001b\[(\d+(;\d+)*)?m/g; // eslint-disable-line no-control-regex const GET_SERVER_LOGS_FEATURE = 'get_server_logs'; type XCUITestDriverLogTypes = keyof typeof SUPPORTED_LOG_TYPES; interface BiDiListenerProperties { type: string; srcEventName?: string; context?: string; entryTransformer?: (x: any) => LogEntry; } /** * @privateRemarks The return types for these getters should be specified */ const SUPPORTED_LOG_TYPES: LogDefRecord = { syslog: { description: 'System Logs - Device logs for iOS applications on real devices and simulators', getter: async (self) => await self.extractLogs('syslog', self.logs), }, crashlog: { description: 'Crash Logs - Crash reports for iOS applications on real devices and simulators', getter: async (self) => await self.extractLogs('crashlog', self.logs), }, performance: { description: 'Performance Logs - Debug Timelines on real devices and simulators', getter: async (self) => await self.extractLogs('performance', self.logs), }, safariConsole: { description: 'Safari Console Logs - data written to the JS console in Safari', getter: async (self) => await self.extractLogs('safariConsole', self.logs), }, safariNetwork: { description: 'Safari Network Logs - information about network operations undertaken by Safari', getter: async (self) => await self.extractLogs('safariNetwork', self.logs), }, server: { description: 'Appium server logs', getter: (self) => { self.assertFeatureEnabled(GET_SERVER_LOGS_FEATURE); return self.log.unwrap().record.map(nativeLogEntryToSeleniumEntry); }, }, }; const LOG_NAMES_TO_CAPABILITY_NAMES_MAP: Record<string, string> = { safariConsole: 'showSafariConsoleLog', safariNetwork: 'showSafariNetworkLog', enablePerformanceLogging: 'enablePerformanceLogging', }; export const supportedLogTypes = SUPPORTED_LOG_TYPES; /** * Extracts logs of the specified type from the logs container. * * @param logType - The type of log to extract * @param logsContainer - Container holding log objects * @returns The extracted logs * @throws {Error} If logs are not available or the log type is not found */ export async function extractLogs( this: XCUITestDriver, logType: XCUITestDriverLogTypes, logsContainer: Partial<Record<XCUITestDriverLogTypes, {getLogs(): Promise<any>}>> = {}, ): Promise<any> { // make sure that we have logs at all // otherwise it's not been initialized if (_.isEmpty(logsContainer)) { throw new Error('No logs currently available. Is the device/simulator started?'); } // If logs captured successfully send response with data, else send error const logObject = logsContainer[logType]; if (logObject) { return await logObject.getLogs(); } if (logType in LOG_NAMES_TO_CAPABILITY_NAMES_MAP) { throw new Error( `${logType} logs are not enabled. Make sure you've set a proper value ` + `to the 'appium:${LOG_NAMES_TO_CAPABILITY_NAMES_MAP[logType]}' capability.` ); } throw new Error( `No logs of type '${logType}' found. Supported log types are: ${_.keys(SUPPORTED_LOG_TYPES)}.` ); } /** * Starts capturing iOS system logs. * * Initializes and starts capturing syslog and crashlog. Optionally starts Safari console and network logs * if the corresponding capabilities are enabled. * * @returns `true` if syslog capture started successfully; `false` otherwise */ export async function startLogCapture(this: XCUITestDriver): Promise<boolean> { this.logs = this.logs || {}; if (!_.isUndefined(this.logs.syslog) && this.logs.syslog.isCapturing) { this.log.warn('Trying to start iOS log capture but it has already started!'); return true; } if (_.isUndefined(this.logs.syslog)) { [this.logs.crashlog,] = assignBiDiLogListener.bind(this)( new IOSCrashLog({ sim: this.device as Simulator, udid: this.isRealDevice() ? this.opts.udid : undefined, log: this.log, }), { type: 'crashlog', } ); [this.logs.syslog,] = assignBiDiLogListener.bind(this)( this.isRealDevice() ? new IOSDeviceLog({ udid: this.opts.udid as string, showLogs: this.opts.showIOSLog, log: this.log, }) : new IOSSimulatorLog({ sim: this.device as Simulator, showLogs: this.opts.showIOSLog, iosSimulatorLogsPredicate: this.opts.iosSimulatorLogsPredicate, simulatorLogLevel: this.opts.simulatorLogLevel, log: this.log, iosSyslogFile: this.opts.iosSyslogFile }), { type: 'syslog', } ); if (_.isBoolean(this.opts.showSafariConsoleLog)) { [this.logs.safariConsole,] = assignBiDiLogListener.bind(this)( new SafariConsoleLog({ showLogs: this.opts.showSafariConsoleLog, log: this.log, }), { type: 'safariConsole', } ); } if (_.isBoolean(this.opts.showSafariNetworkLog)) { [this.logs.safariNetwork,] = assignBiDiLogListener.bind(this)( new SafariNetworkLog({ showLogs: this.opts.showSafariNetworkLog, log: this.log, }), { type: 'safariNetwork', } ); } if (this.isFeatureEnabled(GET_SERVER_LOGS_FEATURE)) { [, this._bidiServerLogListener] = assignBiDiLogListener.bind(this)( this.log.unwrap(), { type: 'server', srcEventName: 'log', entryTransformer: nativeLogEntryToSeleniumEntry, } ); } } let didStartSyslog = false; const promises: Promise<any>[] = [ (async () => { try { await this.logs.syslog?.startCapture(); didStartSyslog = true; this.eventEmitter.emit('syslogStarted', this.logs.syslog); } catch (err: any) { this.log.debug(err.stack); this.log.warn(`Continuing without capturing device logs: ${err.message}`); } })(), this.logs.crashlog?.startCapture() ?? B.resolve(), ]; await B.all(promises); return didStartSyslog; } /** * Starts an iOS system logs broadcast websocket. * * The websocket listens on the same host and port as Appium. The endpoint created is `/ws/session/:sessionId:/appium/syslog`. * * If the websocket is already running, this command does nothing. * * Each connected websocket listener will receive syslog lines as soon as they are visible to Appium. * @see https://appiumpro.com/editions/55-using-mobile-execution-commands-to-continuously-stream-device-logs-with-appium */ export async function mobileStartLogsBroadcast(this: XCUITestDriver): Promise<void> { const pathname = WEBSOCKET_ENDPOINT(this.sessionId as string); if ( !_.isEmpty( await (this.server as AppiumServer).getWebSocketHandlers(pathname), ) ) { this.log.debug( `The system logs broadcasting web socket server is already listening at ${pathname}`, ); return; } this.log.info(`Assigning system logs broadcasting web socket server to ${pathname}`); // https://github.com/websockets/ws/blob/master/doc/ws.md const wss = new WebSocket.Server({ noServer: true, }); wss.on('connection', (ws, req) => { if (req) { const remoteIp = _.isEmpty(req.headers['x-forwarded-for']) ? req.connection?.remoteAddress : req.headers['x-forwarded-for']; this.log.debug(`Established a new system logs listener web socket connection from ${remoteIp}`); } else { this.log.debug('Established a new system logs listener web socket connection'); } if (_.isEmpty(this._syslogWebsocketListener)) { this._syslogWebsocketListener = (logRecord: {message: string}) => { if (ws?.readyState === WebSocket.OPEN) { ws.send(logRecord.message); } }; } this.logs.syslog?.on('output', this._syslogWebsocketListener); ws.on('close', (code: number, reason: Buffer) => { if (!_.isEmpty(this._syslogWebsocketListener)) { this.logs.syslog?.removeListener('output', this._syslogWebsocketListener); this._syslogWebsocketListener = null; } let closeMsg = 'System logs listener web socket is closed.'; if (!_.isEmpty(code)) { closeMsg += ` Code: ${code}.`; } if (!_.isEmpty(reason)) { closeMsg += ` Reason: ${reason.toString()}.`; } this.log.debug(closeMsg); }); }); await (this.server as AppiumServer).addWebSocketHandler( pathname, wss as WSServer, ); } /** * Stops the syslog broadcasting websocket server previously started by `mobile: startLogsBroadcast`. * * If no websocket server is running, this command does nothing. */ export async function mobileStopLogsBroadcast(this: XCUITestDriver): Promise<void> { const pathname = WEBSOCKET_ENDPOINT(this.sessionId as string); if (_.isEmpty(await (this.server as AppiumServer).getWebSocketHandlers(pathname))) { return; } this.log.debug('Stopping the system logs broadcasting web socket server'); await (this.server as AppiumServer).removeWebSocketHandler(pathname); } /** * Assigns a BiDi log listener to the given log emitter. * * https://w3c.github.io/webdriver-bidi/#event-log-entryAdded * * @template EE extends EventEmitter * @param logEmitter - The event emitter to attach the listener to * @param properties - Configuration for the BiDi listener * @returns A tuple containing the log emitter and the listener function */ export function assignBiDiLogListener<EE extends EventEmitter>( this: XCUITestDriver, logEmitter: EE, properties: BiDiListenerProperties, ): [EE, LogListener] { const { type, context = NATIVE_WIN, srcEventName = 'output', entryTransformer, } = properties; const listener: LogListener = (logEntry: LogEntry) => { const finalEntry = entryTransformer ? entryTransformer(logEntry) : logEntry; this.eventEmitter.emit(BIDI_EVENT_NAME, makeLogEntryAddedEvent(finalEntry, context, type)); }; logEmitter.on(srcEventName, listener); return [logEmitter, listener]; } function nativeLogEntryToSeleniumEntry(x: any): LogEntry { const msg = _.isEmpty(x.prefix) ? x.message : `[${x.prefix}] ${x.message}`; return toLogEntry( _.replace(msg, COLOR_CODE_PATTERN, ''), x.timestamp ?? Date.now() ); }