From 4f7045bc76b1550c925a73b80ffdc7de88acf176 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Sun, 8 Sep 2024 10:51:26 +0200 Subject: [PATCH] feat: Emit logs over BiDi socket (#955) --- lib/commands/device/common.js | 18 ++++++++ lib/commands/log.js | 80 ++++++++++++++++++++++++----------- lib/driver.ts | 10 +++++ lib/utils.js | 34 +++++++++++++++ 4 files changed, 117 insertions(+), 25 deletions(-) diff --git a/lib/commands/device/common.js b/lib/commands/device/common.js index e4c0dae7..cfd5cf91 100644 --- a/lib/commands/device/common.js +++ b/lib/commands/device/common.js @@ -12,6 +12,10 @@ import { } from './utils'; import {adjustTimeZone} from '../time'; import { retryInterval } from 'asyncbox'; +import { + GET_SERVER_LOGS_FEATURE, + nativeLogEntryToSeleniumEntry +} from '../../utils'; /** * @this {AndroidDriver} @@ -247,6 +251,11 @@ export async function initDevice() { filterSpecs: logcatFilterSpecs, }); this.eventEmitter.emit('syslogStarted', this.adb.logcat); + if (this.adb.logcat) { + this.assignBiDiLogListener(this.adb.logcat, { + type: 'syslog', + }); + } }; setupPromises.push(logcatStartupPromise()); } @@ -272,6 +281,15 @@ export async function initDevice() { if (timeZone) { setupPromises.push(adjustTimeZone.bind(this)(timeZone)); } + if (this.isFeatureEnabled(GET_SERVER_LOGS_FEATURE)) { + [, this._bidiServerLogListener] = this.assignBiDiLogListener( + this.log.unwrap(), { + type: 'server', + srcEventName: 'log', + entryTransformer: nativeLogEntryToSeleniumEntry, + } + ); + } await B.all(setupPromises); } diff --git a/lib/commands/log.js b/lib/commands/log.js index 0bf65b5e..0aa1fb97 100644 --- a/lib/commands/log.js +++ b/lib/commands/log.js @@ -2,8 +2,13 @@ import {DEFAULT_WS_PATHNAME_PREFIX, BaseDriver} from 'appium/driver'; import _ from 'lodash'; import os from 'node:os'; import WebSocket from 'ws'; - -const GET_SERVER_LOGS_FEATURE = 'get_server_logs'; +import { + BIDI_EVENT_NAME, + GET_SERVER_LOGS_FEATURE, + toLogRecord, + nativeLogEntryToSeleniumEntry, +} from '../utils'; +import { NATIVE_WIN } from './context/helpers'; export const supportedLogTypes = { logcat: { @@ -25,7 +30,7 @@ export const supportedLogTypes = { getter: async (self) => { const output = await /** @type {ADB} */ (self.adb).bugreport(); const timestamp = Date.now(); - return output.split(os.EOL).map((x) => toLogRecord(timestamp, 'ALL', x)); + return output.split(os.EOL).map((x) => toLogRecord(timestamp, x)); }, }, server: { @@ -37,14 +42,7 @@ export const supportedLogTypes = { */ getter: (self) => { self.ensureFeatureEnabled(GET_SERVER_LOGS_FEATURE); - return self.log - .unwrap() - .record.map((x) => toLogRecord( - /** @type {any} */ (x).timestamp ?? Date.now(), - 'ALL', - _.isEmpty(x.prefix) ? x.message : `[${x.prefix}] ${x.message}`, - ), - ); + return self.log.unwrap().record.map(nativeLogEntryToSeleniumEntry); }, }, }; @@ -155,6 +153,42 @@ export async function getLogTypes() { return nativeLogTypes; } +/** + * https://w3c.github.io/webdriver-bidi/#event-log-entryAdded + * + * @template {import('node:events').EventEmitter} EE + * @this {import('../driver').AndroidDriver} + * @param {EE} logEmitter + * @param {BiDiListenerProperties} properties + * @returns {[EE, LogListener]} + */ +export function assignBiDiLogListener (logEmitter, properties) { + const { + type, + context = NATIVE_WIN, + srcEventName = 'output', + entryTransformer, + } = properties; + const listener = (/** @type {import('../utils').LogEntry} */ logEntry) => { + const finalEntry = entryTransformer ? entryTransformer(logEntry) : logEntry; + this.eventEmitter.emit(BIDI_EVENT_NAME, { + context, + method: 'log.entryAdded', + params: { + type, + level: finalEntry.level, + source: { + realm: '', + }, + text: finalEntry.message, + timestamp: finalEntry.timestamp, + }, + }); + }; + logEmitter.on(srcEventName, listener); + return [logEmitter, listener]; +} + /** * @this {import('../driver').AndroidDriver} * @param {string} logType @@ -179,23 +213,19 @@ export async function getLog(logType) { const WEBSOCKET_ENDPOINT = (sessionId) => `${DEFAULT_WS_PATHNAME_PREFIX}/session/${sessionId}/appium/device/logcat`; -/** - * - * @see {@link https://github.com/SeleniumHQ/selenium/blob/0d425676b3c9df261dd641917f867d4d5ce7774d/java/client/src/org/openqa/selenium/logging/LogEntry.java} - * @param {number} timestamp - * @param {string} level - * @param {string} message - */ -function toLogRecord(timestamp, level, message) { - return { - timestamp, - level, - message, - }; -} // #endregion /** * @typedef {import('appium-adb').ADB} ADB */ + +/** + * @typedef {Object} BiDiListenerProperties + * @property {string} type + * @property {string} [srcEventName='output'] + * @property {string} [context=NATIVE_WIN] + * @property {(x: Object) => import('../utils').LogEntry} [entryTransformer] + */ + +/** @typedef {(logEntry: import('../utils').LogEntry) => any} LogListener */ \ No newline at end of file diff --git a/lib/driver.ts b/lib/driver.ts index b245184d..34495cc1 100644 --- a/lib/driver.ts +++ b/lib/driver.ts @@ -160,6 +160,7 @@ import { mobileStopLogsBroadcast, getLogTypes, getLog, + assignBiDiLogListener, } from './commands/log'; import { mobileIsMediaProjectionRecordingRunning, @@ -252,6 +253,8 @@ class AndroidDriver _logcatWebsocketListener?: LogcatListener; + _bidiServerLogListener?: (...args: any[]) => void; + opts: AndroidDriverOpts; constructor(opts: InitialOpts = {} as InitialOpts, shouldValidateCaps = true) { @@ -271,6 +274,7 @@ class AndroidDriver this.curContext = this.defaultContextName(); this.opts = opts as AndroidDriverOpts; this._cachedActivityArgs = {}; + this.doesSupportBidi = true; } get settingsApp(): SettingsApp { @@ -330,11 +334,16 @@ class AndroidDriver } try { + this.adb?.logcat?.removeAllListeners(); await this.adb?.stopLogcat(); } catch (e) { this.log.warn(`Cannot stop the logcat process. Original error: ${e.message}`); } + if (this._bidiServerLogListener) { + this.log.unwrap().off('log', this._bidiServerLogListener); + } + await super.deleteSession(sessionId); } @@ -488,6 +497,7 @@ class AndroidDriver mobileStopLogsBroadcast = mobileStopLogsBroadcast; getLogTypes = getLogTypes; getLog = getLog; + assignBiDiLogListener = assignBiDiLogListener; mobileIsMediaProjectionRecordingRunning = mobileIsMediaProjectionRecordingRunning; mobileStartMediaProjectionRecording = mobileStartMediaProjectionRecording; diff --git a/lib/utils.js b/lib/utils.js index 69e3926f..da1b424e 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -2,6 +2,8 @@ import _ from 'lodash'; import {errors} from 'appium/driver'; export const ADB_SHELL_FEATURE = 'adb_shell'; +export const BIDI_EVENT_NAME = 'bidiEvent'; +export const GET_SERVER_LOGS_FEATURE = 'get_server_logs'; /** * Assert the presence of particular keys in the given object @@ -55,3 +57,35 @@ export async function removeAllSessionWebSocketHandlers(server, sessionId) { await server.removeWebSocketHandler(pathname); } } + +/** + * + * @param {Object} x + * @returns {LogEntry} + */ +export function nativeLogEntryToSeleniumEntry (x) { + return toLogRecord( + _.isEmpty(x.prefix) ? x.message : `[${x.prefix}] ${x.message}`, + /** @type {any} */ (x).timestamp ?? Date.now() + ); +} + +/** + * + * @see {@link https://github.com/SeleniumHQ/selenium/blob/0d425676b3c9df261dd641917f867d4d5ce7774d/java/client/src/org/openqa/selenium/logging/LogEntry.java} + * @param {number} timestamp + * @param {string} message + * @param {string} [level='ALL'] + * @returns {LogEntry} + */ +export function toLogRecord(timestamp, message, level = 'ALL') { + return { + timestamp, + level, + message, + }; +} + +/** + * @typedef {import('appium-adb').LogcatRecord} LogEntry + */