appium-xcuitest-driver
Version:
Appium driver for iOS using XCUITest for backend
328 lines (305 loc) • 11.5 kB
text/typescript
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()
);
}