UNPKG

appium-xcuitest-driver

Version:

Appium driver for iOS using XCUITest for backend

328 lines (307 loc) 11.4 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'; /** * Determines the websocket endpoint based on the `sessionId` * @param {string} sessionId * @returns {string} */ const WEBSOCKET_ENDPOINT = (sessionId) => `${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'; /** * * @param {Object} x * @returns {import('./types').LogEntry} */ function nativeLogEntryToSeleniumEntry (x) { const msg = _.isEmpty(x.prefix) ? x.message : `[${x.prefix}] ${x.message}`; return toLogEntry( _.replace(msg, COLOR_CODE_PATTERN, ''), /** @type {any} */ (x).timestamp ?? Date.now() ); } /** * @type {import('@appium/types').LogDefRecord} * @privateRemarks The return types for these getters should be specified */ const SUPPORTED_LOG_TYPES = { 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', /** * @returns {import('./types').LogEntry[]} */ getter: (self) => { self.assertFeatureEnabled(GET_SERVER_LOGS_FEATURE); return self.log.unwrap().record.map(nativeLogEntryToSeleniumEntry); }, }, }; const LOG_NAMES_TO_CAPABILITY_NAMES_MAP = { safariConsole: 'showSafariConsoleLog', safariNetwork: 'showSafariNetworkLog', enablePerformanceLogging: 'enablePerformanceLogging', }; export default { supportedLogTypes: SUPPORTED_LOG_TYPES, /** * * @param {XCUITestDriverLogTypes} logType * @param {Partial<Record<XCUITestDriverLogTypes,{getLogs(): Promise<any>}>>} [logsContainer] * @this {XCUITestDriver} */ async extractLogs(logType, logsContainer = {}) { // 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)}.` ); }, /** * @this {XCUITestDriver} */ async startLogCapture() { 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: /** @type {import('appium-ios-simulator').Simulator} */ (this.device), 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, showLogs: this.opts.showIOSLog, log: this.log, }) : new IOSSimulatorLog({ sim: /** @type {import('appium-ios-simulator').Simulator} */ (this.device), showLogs: this.opts.showIOSLog, iosSimulatorLogsPredicate: this.opts.iosSimulatorLogsPredicate, simulatorLogLevel: this.opts.simulatorLogLevel, log: this.log, }), { 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; /** @type {Promise[]} */ const promises = [ (async () => { try { await this.logs.syslog?.startCapture(); didStartSyslog = true; this.eventEmitter.emit('syslogStarted', this.logs.syslog); } catch (err) { 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 webcoket 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 * @returns {Promise<void>} * @this {XCUITestDriver} */ async mobileStartLogsBroadcast() { const pathname = WEBSOCKET_ENDPOINT(/** @type {string} */ (this.sessionId)); if ( !_.isEmpty( await /** @type {import('@appium/types').AppiumServer} */ ( this.server ).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) => { if (ws?.readyState === WebSocket.OPEN) { ws.send(logRecord.message); } }; } this.logs.syslog?.on('output', this._syslogWebsocketListener); ws.on('close', (code, reason) => { 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 /** @type {AppiumServer} */ (this.server).addWebSocketHandler( pathname, /** @type {import('@appium/types').WSServer} */ (wss), ); }, /** * Stops the syslog broadcasting wesocket server previously started by `mobile: startLogsBroadcast`. * If no websocket server is running, this command does nothing. * @this {XCUITestDriver} * @returns {Promise<void>} */ async mobileStopLogsBroadcast() { const pathname = WEBSOCKET_ENDPOINT(/** @type {string} */ (this.sessionId)); if (_.isEmpty(await /** @type {AppiumServer} */ (this.server).getWebSocketHandlers(pathname))) { return; } this.log.debug('Stopping the system logs broadcasting web socket server'); await /** @type {AppiumServer} */ (this.server).removeWebSocketHandler(pathname); }, }; /** * https://w3c.github.io/webdriver-bidi/#event-log-entryAdded * * @template {import('node:events').EventEmitter} EE * @this {XCUITestDriver} * @param {EE} logEmitter * @param {BiDiListenerProperties} properties * @returns {[EE, import('./types').LogListener]} */ export function assignBiDiLogListener (logEmitter, properties) { const { type, context = NATIVE_WIN, srcEventName = 'output', entryTransformer, } = properties; const listener = (/** @type {import('./types').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]; } /** * @typedef {import('../driver').XCUITestDriver} XCUITestDriver * @typedef {keyof typeof SUPPORTED_LOG_TYPES} XCUITestDriverLogTypes * @typedef {import('@appium/types').AppiumServer} AppiumServer */ /** * @typedef {Object} BiDiListenerProperties * @property {string} type * @property {string} [srcEventName='output'] * @property {string} [context=NATIVE_WIN] * @property {(x: Object) => import('./types').LogEntry} [entryTransformer] */