From d1fda8080afaafe4880c36e008bfd248d039965a Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Thu, 25 Jul 2024 09:48:08 +0200 Subject: [PATCH] feat: Refactor mixins and improve their typing (#392) --- lib/mixins/connect.js | 166 ++++------- lib/mixins/cookies.js | 45 +++ lib/mixins/events.js | 26 +- lib/mixins/execute.js | 57 ++-- lib/mixins/index.js | 14 - lib/mixins/message-handlers.js | 103 ++++--- lib/mixins/misc.js | 128 +++++++++ lib/mixins/navigate.js | 56 ++-- lib/mixins/screenshot.js | 34 +++ lib/remote-debugger-real-device.js | 15 +- lib/remote-debugger.js | 438 +++++++++-------------------- lib/rpc/rpc-client.js | 4 +- lib/utils.js | 80 +++--- test/unit/mixins/execute-specs.js | 10 +- 14 files changed, 603 insertions(+), 573 deletions(-) create mode 100644 lib/mixins/cookies.js delete mode 100644 lib/mixins/index.js create mode 100644 lib/mixins/misc.js create mode 100644 lib/mixins/screenshot.js diff --git a/lib/mixins/connect.js b/lib/mixins/connect.js index 06815d7a..80f80ba8 100644 --- a/lib/mixins/connect.js +++ b/lib/mixins/connect.js @@ -1,7 +1,7 @@ -import log from '../logger'; import { - appInfoFromDict, pageArrayFromDict, getDebuggerAppKey, - getPossibleDebuggerAppKeys, simpleStringify, deferredPromise + pageArrayFromDict, + getPossibleDebuggerAppKeys, + simpleStringify, } from '../utils'; import events from './events'; import { timing } from '@appium/support'; @@ -32,62 +32,60 @@ const BLANK_PAGE_URL = 'about:blank'; /** * * @this {import('../remote-debugger').RemoteDebugger} + * @returns {Promise} */ -async function setConnectionKey () { - log.debug('Sending connection key request'); - if (!this.rpcClient) { - throw new Error('rpcClient is undefined. Is the debugger connected?'); - } +export async function setConnectionKey () { + this.log.debug('Sending connection key request'); // send but only wait to make sure the socket worked // as response from Web Inspector can take a long time - await this.rpcClient.send('setConnectionKey', {}, false); + await this.requireRpcClient().send('setConnectionKey', {}, false); } /** * * @this {import('../remote-debugger').RemoteDebugger} + * @param {number} [timeout=APP_CONNECT_TIMEOUT_MS] + * @returns {Promise} */ -async function connect (timeout = APP_CONNECT_TIMEOUT_MS) { +export async function connect (timeout = APP_CONNECT_TIMEOUT_MS) { this.setup(); // initialize the rpc client this.initRpcClient(); - if (!this.rpcClient) { - throw new Error('rpcClient is undefined. Is the debugger connected?'); - } + const rpcClient = this.requireRpcClient(); // listen for basic debugger-level events - this.rpcClient.on('_rpc_reportSetup:', _.noop); - this.rpcClient.on('_rpc_forwardGetListing:', this.onPageChange.bind(this)); - this.rpcClient.on('_rpc_reportConnectedApplicationList:', this.onConnectedApplicationList.bind(this)); - this.rpcClient.on('_rpc_applicationConnected:', this.onAppConnect.bind(this)); - this.rpcClient.on('_rpc_applicationDisconnected:', this.onAppDisconnect.bind(this)); - this.rpcClient.on('_rpc_applicationUpdated:', this.onAppUpdate.bind(this)); - this.rpcClient.on('_rpc_reportConnectedDriverList:', this.onConnectedDriverList.bind(this)); - this.rpcClient.on('_rpc_reportCurrentState:', this.onCurrentState.bind(this)); - this.rpcClient.on('Page.frameDetached', this.frameDetached.bind(this)); - - await this.rpcClient.connect(); + rpcClient.on('_rpc_reportSetup:', _.noop); + rpcClient.on('_rpc_forwardGetListing:', this.onPageChange.bind(this)); + rpcClient.on('_rpc_reportConnectedApplicationList:', this.onConnectedApplicationList.bind(this)); + rpcClient.on('_rpc_applicationConnected:', this.onAppConnect.bind(this)); + rpcClient.on('_rpc_applicationDisconnected:', this.onAppDisconnect.bind(this)); + rpcClient.on('_rpc_applicationUpdated:', this.onAppUpdate.bind(this)); + rpcClient.on('_rpc_reportConnectedDriverList:', this.onConnectedDriverList.bind(this)); + rpcClient.on('_rpc_reportCurrentState:', this.onCurrentState.bind(this)); + rpcClient.on('Page.frameDetached', this.frameDetached.bind(this)); + + await rpcClient.connect(); // get the connection information about the app try { this.setConnectionKey(); if (timeout) { - log.debug(`Waiting up to ${timeout}ms for applications to be reported`); + this.log.debug(`Waiting up to ${timeout}ms for applications to be reported`); try { await waitForCondition(() => !_.isEmpty(this.appDict), { waitMs: timeout, intervalMs: APP_CONNECT_INTERVAL_MS, }); } catch (err) { - log.debug(`Timed out waiting for applications to be reported`); + this.log.debug(`Timed out waiting for applications to be reported`); } } return this.appDict || {}; } catch (err) { - log.error(`Error setting connection key: ${err.message}`); + this.log.error(`Error setting connection key: ${err.message}`); await this.disconnect(); throw err; } @@ -98,7 +96,7 @@ async function connect (timeout = APP_CONNECT_TIMEOUT_MS) { * @this {import('../remote-debugger').RemoteDebugger} * @returns {Promise} */ -async function disconnect () { +export async function disconnect () { if (this.rpcClient) { await this.rpcClient.disconnect(); } @@ -123,40 +121,38 @@ async function disconnect () { * @param {boolean} [ignoreAboutBlankUrl] * @returns {Promise} */ -async function selectApp (currentUrl = null, maxTries = SELECT_APP_RETRIES, ignoreAboutBlankUrl = false) { - log.debug('Selecting application'); - if (!this.rpcClient) { - throw new Error('rpcClient is undefined. Is the debugger connected?'); - } +export async function selectApp (currentUrl = null, maxTries = SELECT_APP_RETRIES, ignoreAboutBlankUrl = false) { + this.log.debug('Selecting application'); + const rpcClient = this.requireRpcClient(); - const shouldCheckForTarget = this.rpcClient.shouldCheckForTarget; - this.rpcClient.shouldCheckForTarget = false; + const shouldCheckForTarget = rpcClient.shouldCheckForTarget; + rpcClient.shouldCheckForTarget = false; try { const timer = new timing.Timer().start(); if (!this.appDict || _.isEmpty(this.appDict)) { - log.debug('No applications currently connected.'); + this.log.debug('No applications currently connected.'); return []; } - const {appIdKey, pageDict} = await this.searchForApp(currentUrl, maxTries, ignoreAboutBlankUrl); + const {appIdKey, pageDict} = await this.searchForApp(currentUrl, maxTries, ignoreAboutBlankUrl) ?? {}; // if, after all this, we have no dictionary, we have failed if (!appIdKey || !pageDict) { - log.errorAndThrow(`Could not connect to a valid app after ${maxTries} tries.`); + throw this.log.errorWithException(`Could not connect to a valid app after ${maxTries} tries.`); } if (this.appIdKey !== appIdKey) { - log.debug(`Received altered app id, updating from '${this.appIdKey}' to '${appIdKey}'`); + this.log.debug(`Received altered app id, updating from '${this.appIdKey}' to '${appIdKey}'`); this.appIdKey = appIdKey; } - logApplicationDictionary(this.appDict); + logApplicationDictionary.bind(this)(this.appDict); // translate the dictionary into a useful form, and return to sender const pageArray = _.isEmpty(this.appDict[appIdKey].pageArray) ? pageArrayFromDict(pageDict) : this.appDict[appIdKey].pageArray; - log.debug(`Finally selecting app ${this.appIdKey}: ${simpleStringify(pageArray)}`); + this.log.debug(`Finally selecting app ${this.appIdKey}: ${simpleStringify(pageArray)}`); /** @type {Page[]} */ const fullPageArray = []; @@ -175,10 +171,10 @@ async function selectApp (currentUrl = null, maxTries = SELECT_APP_RETRIES, igno } } - log.debug(`Selected app after ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`); + this.log.debug(`Selected app after ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`); return fullPageArray; } finally { - this.rpcClient.shouldCheckForTarget = shouldCheckForTarget; + rpcClient.shouldCheckForTarget = shouldCheckForTarget; } } @@ -190,32 +186,28 @@ async function selectApp (currentUrl = null, maxTries = SELECT_APP_RETRIES, igno * @param {boolean} ignoreAboutBlankUrl * @returns {Promise} */ -async function searchForApp (currentUrl, maxTries, ignoreAboutBlankUrl) { +export async function searchForApp (currentUrl, maxTries, ignoreAboutBlankUrl) { const bundleIds = this.includeSafari && !this.isSafari ? [this.bundleId, ...this.additionalBundleIds, SAFARI_BUNDLE_ID] : [this.bundleId, ...this.additionalBundleIds]; let retryCount = 0; try { return await retryInterval(maxTries, SELECT_APP_RETRY_SLEEP_MS, async () => { - if (!this.rpcClient) { - throw new Error('rpcClient is undefined. Is the debugger connected?'); - } - - logApplicationDictionary(this.appDict); + logApplicationDictionary.bind(this)(this.appDict); const possibleAppIds = getPossibleDebuggerAppKeys(/** @type {string[]} */ (bundleIds), this.appDict); - log.debug(`Trying out the possible app ids: ${possibleAppIds.join(', ')} (try #${retryCount + 1} of ${maxTries})`); + this.log.debug(`Trying out the possible app ids: ${possibleAppIds.join(', ')} (try #${retryCount + 1} of ${maxTries})`); for (const attemptedAppIdKey of possibleAppIds) { try { if (!this.appDict[attemptedAppIdKey].isActive) { - log.debug(`Skipping app '${attemptedAppIdKey}' because it is not active`); + this.log.debug(`Skipping app '${attemptedAppIdKey}' because it is not active`); continue; } - log.debug(`Attempting app '${attemptedAppIdKey}'`); - const [appIdKey, pageDict] = await this.rpcClient.selectApp(attemptedAppIdKey); + this.log.debug(`Attempting app '${attemptedAppIdKey}'`); + const [appIdKey, pageDict] = await this.requireRpcClient().selectApp(attemptedAppIdKey); // in iOS 8.2 the connect logic happens, but with an empty dictionary // which leads to the remote debugger getting disconnected, and into a loop if (_.isEmpty(pageDict)) { - log.debug('Empty page dictionary received. Trying again.'); + this.log.debug('Empty page dictionary received. Trying again.'); continue; } @@ -231,19 +223,19 @@ async function searchForApp (currentUrl, maxTries, ignoreAboutBlankUrl) { } if (currentUrl) { - log.debug(`Received app, but expected url ('${currentUrl}') was not found. Trying again.`); + this.log.debug(`Received app, but expected url ('${currentUrl}') was not found. Trying again.`); } else { - log.debug('Received app, but no match was found. Trying again.'); + this.log.debug('Received app, but no match was found. Trying again.'); } } catch (err) { - log.debug(`Error checking application: '${err.message}'. Retrying connection`); + this.log.debug(`Error checking application: '${err.message}'. Retrying connection`); } } retryCount++; throw new Error('Failed to find an app to select'); }); } catch (ign) { - log.errorAndThrow(`Could not connect to a valid app after ${maxTries} tries.`); + this.log.errorAndThrow(`Could not connect to a valid app after ${maxTries} tries.`); } return null; } @@ -256,7 +248,7 @@ async function searchForApp (currentUrl, maxTries, ignoreAboutBlankUrl) { * @param {boolean} [ignoreAboutBlankUrl] * @returns {AppPages?} */ -function searchForPage (appsDict, currentUrl = null, ignoreAboutBlankUrl = false) { +export function searchForPage (appsDict, currentUrl = null, ignoreAboutBlankUrl = false) { for (const appDict of _.values(appsDict)) { if (!appDict || !appDict.isActive || !appDict.pageArray || appDict.pageArray.promise) { continue; @@ -280,29 +272,26 @@ function searchForPage (appsDict, currentUrl = null, ignoreAboutBlankUrl = false * @param {boolean} [skipReadyCheck] * @returns {Promise} */ -async function selectPage (appIdKey, pageIdKey, skipReadyCheck = false) { +export async function selectPage (appIdKey, pageIdKey, skipReadyCheck = false) { this.appIdKey = _.startsWith(`${appIdKey}`, 'PID:') ? `${appIdKey}` : `PID:${appIdKey}`; this.pageIdKey = pageIdKey; - log.debug(`Selecting page '${pageIdKey}' on app '${this.appIdKey}' and forwarding socket setup`); - if (!this.rpcClient) { - throw new Error('rpcClient is undefined. Is the debugger connected?'); - } + this.log.debug(`Selecting page '${pageIdKey}' on app '${this.appIdKey}' and forwarding socket setup`); const timer = new timing.Timer().start(); - await this.rpcClient.selectPage(this.appIdKey, pageIdKey); + await this.requireRpcClient().selectPage(this.appIdKey, pageIdKey); // make sure everything is ready to go if (!skipReadyCheck && !await this.checkPageIsReady()) { await this.waitForDom(); } - log.debug(`Selected page after ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`); + this.log.debug(`Selected page after ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`); } /** - * + * @this {import('../remote-debugger').RemoteDebugger} * @param {Record} apps * @returns {void} */ @@ -318,56 +307,23 @@ function logApplicationDictionary (apps) { return JSON.stringify(value); } - log.debug('Current applications available:'); + this.log.debug('Current applications available:'); for (const [app, info] of _.toPairs(apps)) { - log.debug(` Application: "${app}"`); + this.log.debug(` Application: "${app}"`); for (const [key, value] of _.toPairs(info)) { if (key === 'pageArray' && Array.isArray(value) && value.length) { - log.debug(` ${key}:`); + this.log.debug(` ${key}:`); for (const page of value) { let prefix = '- '; for (const [k, v] of _.toPairs(page)) { - log.debug(` ${prefix}${k}: ${JSON.stringify(v)}`); + this.log.debug(` ${prefix}${k}: ${JSON.stringify(v)}`); prefix = ' '; } } } else { const valueString = getValueString(key, value); - log.debug(` ${key}: ${valueString}`); + this.log.debug(` ${key}: ${valueString}`); } } } } - -/** - * - * @this {import('../remote-debugger').RemoteDebugger} - * @param {Record} dict - * @returns {void} - */ -function updateAppsWithDict (dict) { - // get the dictionary entry into a nice form, and add it to the - // application dictionary - this.appDict = this.appDict || {}; - let [id, entry] = appInfoFromDict(dict); - if (this.appDict[id]) { - // preserve the page dictionary for this entry - entry.pageArray = this.appDict[id].pageArray; - } - this.appDict[id] = entry; - - // add a promise to get the page dictionary - if (_.isUndefined(entry.pageArray)) { - entry.pageArray = deferredPromise(); - } - - // try to get the app id from our connected apps - if (!this.appIdKey) { - this.appIdKey = getDebuggerAppKey(/** @type {string} */ (this.bundleId), this.appDict); - } -} - -export default { - setConnectionKey, connect, disconnect, selectApp, - searchForApp, searchForPage, selectPage, updateAppsWithDict -}; diff --git a/lib/mixins/cookies.js b/lib/mixins/cookies.js new file mode 100644 index 00000000..3e70205d --- /dev/null +++ b/lib/mixins/cookies.js @@ -0,0 +1,45 @@ + +/** + * + * @this {import('../remote-debugger').RemoteDebugger} + * @returns {Promise} + */ +export async function getCookies () { + this.log.debug('Getting cookies'); + return await this.requireRpcClient().send('Page.getCookies', { + appIdKey: this.appIdKey, + pageIdKey: this.pageIdKey + }); +} + +/** + * + * @this {import('../remote-debugger').RemoteDebugger} + * @param {any} cookie + * @returns {Promise} + */ +export async function setCookie (cookie) { + this.log.debug('Setting cookie'); + return await this.requireRpcClient().send('Page.setCookie', { + appIdKey: this.appIdKey, + pageIdKey: this.pageIdKey, + cookie + }); +} + +/** + * + * @this {import('../remote-debugger').RemoteDebugger} + * @param {string} cookieName + * @param {string} url + * @returns {Promise} + */ +export async function deleteCookie (cookieName, url) { + this.log.debug(`Deleting cookie '${cookieName}' on '${url}'`); + return await this.requireRpcClient().send('Page.deleteCookie', { + appIdKey: this.appIdKey, + pageIdKey: this.pageIdKey, + cookieName, + url, + }); +} diff --git a/lib/mixins/events.js b/lib/mixins/events.js index dee574c0..23fba4fa 100644 --- a/lib/mixins/events.js +++ b/lib/mixins/events.js @@ -1,9 +1,33 @@ // event emitted publically -const events = { +export const events = { EVENT_PAGE_CHANGE: 'remote_debugger_page_change', EVENT_FRAMES_DETACHED: 'remote_debugger_frames_detached', EVENT_DISCONNECT: 'remote_debugger_disconnect', }; +/** + * Keep track of the client event listeners so they can be removed + * + * @this {import('../remote-debugger').RemoteDebugger} + * @param {string} eventName + * @param {(event: import('@appium/types').StringRecord) => any} listener + * @returns {void} + */ +export function addClientEventListener (eventName, listener) { + this._clientEventListeners[eventName] ??= []; + this._clientEventListeners[eventName].push(listener); + this.requireRpcClient().on(eventName, listener); +} + +/** + * @this {import('../remote-debugger').RemoteDebugger} + * @param {string} eventName + * @returns {void} + */ +export function removeClientEventListener (eventName) { + for (const listener of (this._clientEventListeners[eventName] || [])) { + this.requireRpcClient().off(eventName, listener); + } +} export default events; diff --git a/lib/mixins/execute.js b/lib/mixins/execute.js index 4fc5658e..81e509eb 100644 --- a/lib/mixins/execute.js +++ b/lib/mixins/execute.js @@ -1,4 +1,3 @@ -import log from '../logger'; import { errors } from '@appium/base-driver'; import { checkParams, simpleStringify, convertResult, RESPONSE_LOG_LENGTH } from '../utils'; import { getScriptForAtom } from '../atoms'; @@ -15,17 +14,13 @@ const RPC_RESPONSE_TIMEOUT_MS = 5000; * @param {string} atom Name of Selenium atom (see atoms/ directory) * @param {any[]} args Arguments passed to the atom * @param {string[]} frames - * @returns {Promise} The result received from the atom + * @returns {Promise} The result received from the atom */ -async function executeAtom (atom, args = [], frames = []) { - if (!this.rpcClient?.isConnected) { - throw new Error('Remote debugger is not connected'); - } - - log.debug(`Executing atom '${atom}' with 'args=${JSON.stringify(args)}; frames=${frames}'`); +export async function executeAtom (atom, args = [], frames = []) { + this.log.debug(`Executing atom '${atom}' with 'args=${JSON.stringify(args)}; frames=${frames}'`); const script = await getScriptForAtom(atom, args, frames); const value = await this.execute(script, true); - log.debug(`Received result for atom '${atom}' execution: ${_.truncate(simpleStringify(value), {length: RESPONSE_LOG_LENGTH})}`); + this.log.debug(`Received result for atom '${atom}' execution: ${_.truncate(simpleStringify(value), {length: RESPONSE_LOG_LENGTH})}`); return value; } @@ -36,18 +31,13 @@ async function executeAtom (atom, args = [], frames = []) { * @param {string[]} [frames] * @returns {Promise} */ -async function executeAtomAsync (atom, args = [], frames = []) { +export async function executeAtomAsync (atom, args = [], frames = []) { // helper to send directly to the web inspector - const evaluate = async (method, opts) => { - if (!this.rpcClient?.isConnected) { - throw new Error('Remote debugger is not connected'); - } - return await this.rpcClient.send(method, Object.assign({ - appIdKey: this.appIdKey, - pageIdKey: this.pageIdKey, - returnByValue: false, - }, opts)); - }; + const evaluate = async (method, opts) => await this.requireRpcClient(true).send(method, Object.assign({ + appIdKey: this.appIdKey, + pageIdKey: this.pageIdKey, + returnByValue: false, + }, opts)); // first create a Promise on the page, saving the resolve/reject functions // as properties @@ -94,7 +84,7 @@ async function executeAtomAsync (atom, args = [], frames = []) { // if the timeout math turns up 0 retries, make sure it happens once const retries = parseInt(`${timeout / retryWait}`, 10) || 1; const timer = new timing.Timer().start(); - log.debug(`Waiting up to ${timeout}ms for async execute to finish`); + this.log.debug(`Waiting up to ${timeout}ms for async execute to finish`); res = await retryInterval(retries, retryWait, async () => { // the atom _will_ return, either because it finished or an error // including a timeout error @@ -131,10 +121,10 @@ async function executeAtomAsync (atom, args = [], frames = []) { * @param {boolean} [override] * @returns {Promise} */ -async function execute (command, override) { +export async function execute (command, override) { // if the page is not loaded yet, wait for it if (this.pageLoading && !override) { - log.debug('Trying to execute but page is not loaded.'); + this.log.debug('Trying to execute but page is not loaded.'); await this.waitForDom(); } @@ -149,17 +139,13 @@ async function execute (command, override) { await this.garbageCollect(); } - log.debug(`Sending javascript command: '${_.truncate(command, {length: 50})}'`); - if (!this.rpcClient?.isConnected) { - throw new Error('Remote debugger is not connected'); - } - const res = await this.rpcClient.send('Runtime.evaluate', { + this.log.debug(`Sending javascript command: '${_.truncate(command, {length: 50})}'`); + const res = await this.requireRpcClient(true).send('Runtime.evaluate', { expression: command, returnByValue: true, appIdKey: this.appIdKey, pageIdKey: this.pageIdKey, }); - return convertResult(res); } @@ -169,19 +155,15 @@ async function execute (command, override) { * @param {any} fn * @param {any[]} [args] */ -async function callFunction (objectId, fn, args) { - if (!this.rpcClient?.isConnected) { - throw new Error('Remote debugger is not connected'); - } - +export async function callFunction (objectId, fn, args) { checkParams({appIdKey: this.appIdKey, pageIdKey: this.pageIdKey}); if (this.garbageCollectOnExecute) { await this.garbageCollect(); } - log.debug('Calling javascript function'); - const res = await this.rpcClient.send('Runtime.callFunctionOn', { + this.log.debug('Calling javascript function'); + const res = await this.requireRpcClient(true).send('Runtime.callFunctionOn', { objectId, functionDeclaration: fn, arguments: args, @@ -192,6 +174,3 @@ async function callFunction (objectId, fn, args) { return convertResult(res); } - - -export default { executeAtom, executeAtomAsync, execute, callFunction }; diff --git a/lib/mixins/index.js b/lib/mixins/index.js deleted file mode 100644 index 2c330264..00000000 --- a/lib/mixins/index.js +++ /dev/null @@ -1,14 +0,0 @@ -import connect from './connect'; -import events from './events'; -import execute from './execute'; -import navigate from './navigate'; -import messageHandlers from './message-handlers'; - - -const mixins = Object.assign({}, - connect, events, execute, navigate, messageHandlers -); - - -export { mixins, events }; -export default mixins; diff --git a/lib/mixins/message-handlers.js b/lib/mixins/message-handlers.js index 95e19ce3..51615dbe 100644 --- a/lib/mixins/message-handlers.js +++ b/lib/mixins/message-handlers.js @@ -1,6 +1,11 @@ -import log from '../logger'; import events from './events'; -import { pageArrayFromDict, getDebuggerAppKey, simpleStringify, appInfoFromDict } from '../utils'; +import { + pageArrayFromDict, + getDebuggerAppKey, + simpleStringify, + appInfoFromDict, + deferredPromise, +} from '../utils'; import _ from 'lodash'; @@ -16,14 +21,14 @@ import _ from 'lodash'; * @param {Record} pageDict * @returns {Promise} */ -async function onPageChange (err, appIdKey, pageDict) { +export async function onPageChange (err, appIdKey, pageDict) { if (_.isEmpty(pageDict)) { return; } const pageArray = pageArrayFromDict(pageDict); - await this.useAppDictLock((done) => { + await useAppDictLock.bind(this)((/** @type {() => void} */ done) => { try { // save the page dict for this app if (this.appDict[appIdKey]) { @@ -34,7 +39,7 @@ async function onPageChange (err, appIdKey, pageDict) { } else { // we have a pre-existing pageDict if (_.isEqual(this.appDict[appIdKey].pageArray, pageArray)) { - log.debug(`Received page change notice for app '${appIdKey}' ` + + this.log.debug(`Received page change notice for app '${appIdKey}' ` + `but the listing has not changed. Ignoring.`); return done(); } @@ -53,7 +58,7 @@ async function onPageChange (err, appIdKey, pageDict) { return; } - log.debug(`Page changed: ${simpleStringify(pageDict, true)}`); + this.log.debug(`Page changed: ${simpleStringify(pageDict, true)}`); this.emit(events.EVENT_PAGE_CHANGE, { appIdKey: appIdKey.replace('PID:', ''), @@ -67,12 +72,12 @@ async function onPageChange (err, appIdKey, pageDict) { * @param {Record} dict * @returns {Promise} */ -async function onAppConnect (err, dict) { +export async function onAppConnect (err, dict) { const appIdKey = dict.WIRApplicationIdentifierKey; - log.debug(`Notified that new application '${appIdKey}' has connected`); - await this.useAppDictLock((/** @type {() => Void} */done) => { + this.log.debug(`Notified that new application '${appIdKey}' has connected`); + await useAppDictLock.bind(this)((/** @type {() => void} */ done) => { try { - this.updateAppsWithDict(dict); + updateAppsWithDict.bind(this)(dict); } finally { done(); } @@ -85,10 +90,10 @@ async function onAppConnect (err, dict) { * @param {Record} dict * @returns {void} */ -function onAppDisconnect (err, dict) { +export function onAppDisconnect (err, dict) { const appIdKey = dict.WIRApplicationIdentifierKey; - log.debug(`Application '${appIdKey}' disconnected. Removing from app dictionary.`); - log.debug(`Current app is '${this.appIdKey}'`); + this.log.debug(`Application '${appIdKey}' disconnected. Removing from app dictionary.`); + this.log.debug(`Current app is '${this.appIdKey}'`); // get rid of the entry in our app dictionary, // since it is no longer available @@ -96,13 +101,13 @@ function onAppDisconnect (err, dict) { // if the disconnected app is the one we are connected to, try to find another if (this.appIdKey === appIdKey) { - log.debug(`No longer have app id. Attempting to find new one.`); + this.log.debug(`No longer have app id. Attempting to find new one.`); this.appIdKey = getDebuggerAppKey(/** @type {string} */ (this.bundleId), this.appDict); } if (!this.appDict) { // this means we no longer have any apps. what the what? - log.debug('Main app disconnected. Disconnecting altogether.'); + this.log.debug('Main app disconnected. Disconnecting altogether.'); this.connected = false; this.emit(events.EVENT_DISCONNECT, true); } @@ -114,10 +119,10 @@ function onAppDisconnect (err, dict) { * @param {Record} dict * @returns {Promise} */ -async function onAppUpdate (err, dict) { - await this.useAppDictLock((done) => { +export async function onAppUpdate (err, dict) { + await useAppDictLock.bind(this)((/** @type {() => void} */ done) => { try { - this.updateAppsWithDict(dict); + updateAppsWithDict.bind(this)(dict); } finally { done(); } @@ -130,9 +135,9 @@ async function onAppUpdate (err, dict) { * @param {Record} drivers * @returns {void} */ -function onConnectedDriverList (err, drivers) { +export function onConnectedDriverList (err, drivers) { this.connectedDrivers = drivers.WIRDriverDictionaryKey; - log.debug(`Received connected driver list: ${JSON.stringify(this.connectedDrivers)}`); + this.log.debug(`Received connected driver list: ${JSON.stringify(this.connectedDrivers)}`); } /** @@ -141,11 +146,11 @@ function onConnectedDriverList (err, drivers) { * @param {Record} state * @returns {void} */ -function onCurrentState (err, state) { +export function onCurrentState (err, state) { this.currentState = state.WIRAutomationAvailabilityKey; // This state changes when 'Remote Automation' in 'Settings app' > 'Safari' > 'Advanced' > 'Remote Automation' changes // WIRAutomationAvailabilityAvailable or WIRAutomationAvailabilityNotAvailable - log.debug(`Received connected automation availability state: ${JSON.stringify(this.currentState)}`); + this.log.debug(`Received connected automation availability state: ${JSON.stringify(this.currentState)}`); } /** @@ -154,8 +159,8 @@ function onCurrentState (err, state) { * @param {Record} apps * @returns {Promise} */ -async function onConnectedApplicationList (err, apps) { - log.debug(`Received connected applications list: ${_.keys(apps).join(', ')}`); +export async function onConnectedApplicationList (err, apps) { + this.log.debug(`Received connected applications list: ${_.keys(apps).join(', ')}`); // translate the received information into an easier-to-manage // hash with app id as key, and app info as value @@ -168,7 +173,7 @@ async function onConnectedApplicationList (err, apps) { newDict[id] = entry; } // update the object's list of apps - await this.useAppDictLock((done) => { + await useAppDictLock.bind(this)((/** @type {() => void} */ done) => { try { _.defaults(this.appDict, newDict); } finally { @@ -177,14 +182,40 @@ async function onConnectedApplicationList (err, apps) { }); } -const messageHandlers = { - onPageChange, - onAppConnect, - onAppDisconnect, - onAppUpdate, - onConnectedDriverList, - onCurrentState, - onConnectedApplicationList, -}; - -export default messageHandlers; +/** + * @this {import('../remote-debugger').RemoteDebugger} + * @param {(done: () => any) => any} fn + * @returns {Promise} + */ +async function useAppDictLock (fn) { + return await this._lock.acquire('appDict', fn); +} + + +/** + * + * @this {import('../remote-debugger').RemoteDebugger} + * @param {import('@appium/types').StringRecord} dict + * @returns {void} + */ +function updateAppsWithDict (dict) { + // get the dictionary entry into a nice form, and add it to the + // application dictionary + this.appDict = this.appDict || {}; + let [id, entry] = appInfoFromDict(dict); + if (this.appDict[id]) { + // preserve the page dictionary for this entry + entry.pageArray = this.appDict[id].pageArray; + } + this.appDict[id] = entry; + + // add a promise to get the page dictionary + if (_.isUndefined(entry.pageArray)) { + entry.pageArray = deferredPromise(); + } + + // try to get the app id from our connected apps + if (!this.appIdKey) { + this.appIdKey = getDebuggerAppKey(/** @type {string} */ (this.bundleId), this.appDict); + } +} diff --git a/lib/mixins/misc.js b/lib/mixins/misc.js new file mode 100644 index 00000000..7f9b6e87 --- /dev/null +++ b/lib/mixins/misc.js @@ -0,0 +1,128 @@ +import { checkParams } from '../utils'; +import B from 'bluebird'; + +const SAFARI_BUNDLE_ID = 'com.apple.mobilesafari'; +const GARBAGE_COLLECT_TIMEOUT_MS = 5000; + +/** + * @this {import('../remote-debugger').RemoteDebugger} + * @returns {Promise} + */ +export async function launchSafari () { + await this.requireRpcClient().send('launchApplication', { + bundleId: SAFARI_BUNDLE_ID + }); +} + +/** + * @this {import('../remote-debugger').RemoteDebugger} + * @param {(event: import('@appium/types').StringRecord) => any} fn + * @returns {Promise} + */ +export async function startTimeline (fn) { + this.log.debug('Starting to record the timeline'); + this.requireRpcClient().on('Timeline.eventRecorded', fn); + return await this.requireRpcClient().send('Timeline.start', { + appIdKey: this.appIdKey, + pageIdKey: this.pageIdKey, + }); +} + +/** + * @this {import('../remote-debugger').RemoteDebugger} + * @returns {Promise} + */ +export async function stopTimeline () { + this.log.debug('Stopping to record the timeline'); + await this.requireRpcClient().send('Timeline.stop', { + appIdKey: this.appIdKey, + pageIdKey: this.pageIdKey, + }); +} + +/** + * @this {import('../remote-debugger').RemoteDebugger} + * @param {(event: import('@appium/types').StringRecord) => any} listener + * @returns {void} + */ +export function startConsole (listener) { + this.log.debug('Starting to listen for JavaScript console'); + this.addClientEventListener('Console.messageAdded', listener); + this.addClientEventListener('Console.messageRepeatCountUpdated', listener); +} + +/** + * @this {import('../remote-debugger').RemoteDebugger} + * @returns {void} + */ +export function stopConsole () { + this.log.debug('Stopping to listen for JavaScript console'); + this.removeClientEventListener('Console.messageAdded'); + this.removeClientEventListener('Console.messageRepeatCountUpdated'); +} + +/** + * @this {import('../remote-debugger').RemoteDebugger} + * @param {(event: import('@appium/types').StringRecord) => any} listener + * @returns {void} + */ +export function startNetwork (listener) { + this.log.debug('Starting to listen for network events'); + this.addClientEventListener('NetworkEvent', listener); +} + +/** + * @this {import('../remote-debugger').RemoteDebugger} + * @returns {void} + */ +export function stopNetwork () { + this.log.debug('Stopping to listen for network events'); + this.removeClientEventListener('NetworkEvent'); +} + +// Potentially this does not work for mobile safari +/** + * @this {import('../remote-debugger').RemoteDebugger} + * @param {string} value + * @returns {Promise} + */ +export async function overrideUserAgent (value) { + this.log.debug('Setting overrideUserAgent'); + return await this.requireRpcClient().send('Page.overrideUserAgent', { + appIdKey: this.appIdKey, + pageIdKey: this.pageIdKey, + value + }); +} + +/** + * @this {import('../remote-debugger').RemoteDebugger} + * @param {number} [timeoutMs=GARBAGE_COLLECT_TIMEOUT_MS] + * @returns {Promise} + */ +export async function garbageCollect (timeoutMs = GARBAGE_COLLECT_TIMEOUT_MS) { + this.log.debug(`Garbage collecting with ${timeoutMs}ms timeout`); + + try { + checkParams({appIdKey: this.appIdKey, pageIdKey: this.pageIdKey}); + } catch (err) { + this.log.debug(`Unable to collect garbage at this time`); + return; + } + + try { + await B.resolve(this.requireRpcClient().send( + 'Heap.gc', { + appIdKey: this.appIdKey, + pageIdKey: this.pageIdKey, + }) + ).timeout(timeoutMs); + this.log.debug(`Garbage collection successful`); + } catch (e) { + if (e instanceof B.TimeoutError) { + this.log.debug(`Garbage collection timed out after ${timeoutMs}ms`); + } else { + this.log.debug(`Unable to collect garbage: ${e.message}`); + } + } +} diff --git a/lib/mixins/navigate.js b/lib/mixins/navigate.js index 1049826d..fbde3b33 100644 --- a/lib/mixins/navigate.js +++ b/lib/mixins/navigate.js @@ -1,4 +1,3 @@ -import log from '../logger'; import { checkParams } from '../utils'; import events from './events'; import { timing, util } from '@appium/support'; @@ -25,7 +24,7 @@ const PAGE_LOAD_STRATEGY = { * @this {import('../remote-debugger').RemoteDebugger} * @returns {void} */ -function frameDetached () { +export function frameDetached () { this.emit(events.EVENT_FRAMES_DETACHED); } @@ -33,8 +32,8 @@ function frameDetached () { * @this {import('../remote-debugger').RemoteDebugger} * @returns {void} */ -function cancelPageLoad () { - log.debug('Unregistering from page readiness notifications'); +export function cancelPageLoad () { + this.log.debug('Unregistering from page readiness notifications'); this.pageLoading = false; if (this.pageLoadDelay) { this.pageLoadDelay.cancel(); @@ -48,7 +47,7 @@ function cancelPageLoad () { * @param {string} readyState * @returns {boolean} */ -function isPageLoadingCompleted (readyState) { +export function isPageLoadingCompleted (readyState) { const _pageLoadStrategy = _.toLower(this.pageLoadStrategy); if (_pageLoadStrategy === PAGE_LOAD_STRATEGY.NONE) { return true; @@ -65,14 +64,14 @@ function isPageLoadingCompleted (readyState) { /** * @this {import('../remote-debugger').RemoteDebugger} - * @param {timing.Timer|null|undefined} startPageLoadTimer + * @param {timing.Timer?} [startPageLoadTimer] * @returns {Promise} */ -async function waitForDom (startPageLoadTimer) { - log.debug('Waiting for page readiness'); +export async function waitForDom (startPageLoadTimer) { + this.log.debug('Waiting for page readiness'); const readinessTimeoutMs = this.pageLoadMs || DEFAULT_PAGE_READINESS_TIMEOUT_MS; if (!_.isFunction(startPageLoadTimer?.getDuration)) { - log.debug(`Page load timer not a timer. Creating new timer`); + this.log.debug(`Page load timer not a timer. Creating new timer`); startPageLoadTimer = new timing.Timer().start(); } @@ -94,7 +93,7 @@ async function waitForDom (startPageLoadTimer) { await B.delay(intervalMs); // we can get this called in the middle of trying to find a new app if (!this.appIdKey) { - log.debug('Not connected to an application. Ignoring page readiess check'); + this.log.debug('Not connected to an application. Ignoring page readiess check'); return; } if (!isPageLoading) { @@ -103,13 +102,13 @@ async function waitForDom (startPageLoadTimer) { if (await this.checkPageIsReady()) { if (isPageLoading) { - log.debug(`Page is ready in ${elapsedMs}ms`); + this.log.debug(`Page is ready in ${elapsedMs}ms`); isPageLoading = false; } return; } if (elapsedMs > readinessTimeoutMs) { - log.info(`Timed out after ${readinessTimeoutMs}ms of waiting for the page readiness. Continuing anyway`); + this.log.info(`Timed out after ${readinessTimeoutMs}ms of waiting for the page readiness. Continuing anyway`); isPageLoading = false; return; } @@ -137,21 +136,21 @@ async function waitForDom (startPageLoadTimer) { * @param {number} [timeoutMs] * @returns {Promise} */ -async function checkPageIsReady (timeoutMs) { +export async function checkPageIsReady (timeoutMs) { checkParams({appIdKey: this.appIdKey}); const readyCmd = 'document.readyState;'; try { const readyState = await B.resolve(this.execute(readyCmd, true)) .timeout(timeoutMs ?? this.pageReadyTimeout); - log.debug(`Document readyState is '${readyState}'. ` + + this.log.debug(`Document readyState is '${readyState}'. ` + `The pageLoadStrategy is '${this.pageLoadStrategy || PAGE_LOAD_STRATEGY.NORMAL}'`); return this.isPageLoadingCompleted(readyState); } catch (err) { if (!(err instanceof B.TimeoutError)) { throw err; } - log.debug(`Page readiness check timed out after ${this.pageReadyTimeout}ms`); + this.log.debug(`Page readiness check timed out after ${this.pageReadyTimeout}ms`); return false; } } @@ -161,11 +160,10 @@ async function checkPageIsReady (timeoutMs) { * @param {string} url * @returns {Promise} */ -async function navToUrl (url) { +export async function navToUrl (url) { checkParams({appIdKey: this.appIdKey, pageIdKey: this.pageIdKey}); - if (!this.rpcClient) { - throw new Error('rpcClient is undefined. Is the debugger connected?'); - } + const rpcClient = this.requireRpcClient(); + try { new URL(url); } catch (e) { @@ -173,7 +171,7 @@ async function navToUrl (url) { } this._navigatingToPage = true; - log.debug(`Navigating to new URL: '${url}'`); + this.log.debug(`Navigating to new URL: '${url}'`); const readinessTimeoutMs = this.pageLoadMs || DEFAULT_PAGE_READINESS_TIMEOUT_MS; /** @type {(() => void)|undefined} */ let onPageLoaded; @@ -192,7 +190,7 @@ async function navToUrl (url) { onPageLoadedTimeout = setTimeout(() => { if (isPageLoading) { isPageLoading = false; - log.info( + this.log.info( `Timed out after ${start.getDuration().asMilliSeconds.toFixed(0)}ms of waiting ` + `for the ${url} page readiness. Continuing anyway` ); @@ -203,7 +201,7 @@ async function navToUrl (url) { onPageLoaded = () => { if (isPageLoading) { isPageLoading = false; - log.debug(`The page ${url} is ready in ${start.getDuration().asMilliSeconds.toFixed(0)}ms`); + this.log.debug(`The page ${url} is ready in ${start.getDuration().asMilliSeconds.toFixed(0)}ms`); } if (onPageLoadedTimeout) { clearTimeout(onPageLoadedTimeout); @@ -213,7 +211,7 @@ async function navToUrl (url) { return resolve(); }; // https://chromedevtools.github.io/devtools-protocol/tot/Page/#event-loadEventFired - this.rpcClient?.once('Page.loadEventFired', onPageLoaded); + rpcClient.once('Page.loadEventFired', onPageLoaded); // Pages that have no proper DOM structure do not fire the `Page.loadEventFired` event // so we rely on the very first event after target change, which is `onTargetProvisioned` // and start sending `document.readyState` requests until we either succeed or @@ -233,9 +231,9 @@ async function navToUrl (url) { } } }; - this.rpcClient?.targetSubscriptions.once(rpcConstants.ON_TARGET_PROVISIONED_EVENT, onTargetProvisioned); + rpcClient.targetSubscriptions.once(rpcConstants.ON_TARGET_PROVISIONED_EVENT, onTargetProvisioned); - this.rpcClient?.send('Page.navigate', { + rpcClient.send('Page.navigate', { url, appIdKey: this.appIdKey, pageIdKey: this.pageIdKey, @@ -260,17 +258,17 @@ async function navToUrl (url) { onPageLoadedTimeout = null; } if (onTargetProvisioned) { - this.rpcClient.targetSubscriptions.off(rpcConstants.ON_TARGET_PROVISIONED_EVENT, onTargetProvisioned); + rpcClient.targetSubscriptions.off(rpcConstants.ON_TARGET_PROVISIONED_EVENT, onTargetProvisioned); } if (onPageLoaded) { - this.rpcClient.off('Page.loadEventFired', onPageLoaded); + rpcClient.off('Page.loadEventFired', onPageLoaded); } } // enable console logging, so we get the events (otherwise we only // get notified when navigating to a local page try { - await B.resolve(this.rpcClient.send('Console.enable', { + await B.resolve(rpcClient.send('Console.enable', { appIdKey: this.appIdKey, pageIdKey: this.pageIdKey, }, didPageFinishLoad)).timeout(CONSOLE_ENABLEMENT_TIMEOUT_MS); @@ -282,5 +280,3 @@ async function navToUrl (url) { throw err; } } - -export default {frameDetached, cancelPageLoad, waitForDom, checkPageIsReady, navToUrl, isPageLoadingCompleted}; diff --git a/lib/mixins/screenshot.js b/lib/mixins/screenshot.js new file mode 100644 index 00000000..3280e4b1 --- /dev/null +++ b/lib/mixins/screenshot.js @@ -0,0 +1,34 @@ +/** + * Capture a rect of the page or by default the viewport + * @this {import('../remote-debugger').RemoteDebugger} + * @param {ScreenshotCaptureOptions} [opts={}] if rect is null capture the whole + * coordinate system else capture the rect in the given coordinateSystem + * @returns {Promise} a base64 encoded string of the screenshot + */ +export async function captureScreenshot(opts = {}) { + const {rect = null, coordinateSystem = 'Viewport'} = opts; + this.log.debug('Capturing screenshot'); + + const arect = rect ?? /** @type {import('@appium/types').Rect} */ (await this.executeAtom( + 'execute_script', + ['return {x: 0, y: 0, width: window.innerWidth, height: window.innerHeight}', []] + )); + const response = await this.requireRpcClient().send('Page.snapshotRect', { + ...arect, + appIdKey: this.appIdKey, + pageIdKey: this.pageIdKey, + coordinateSystem, + }); + + if (response.error) { + throw new Error(response.error); + } + + return response.dataURL.replace(/^data:image\/png;base64,/, ''); +} + +/** + * @typedef {Object} ScreenshotCaptureOptions + * @property {import('@appium/types').Rect | null} [rect=null] + * @property {"Viewport" | "Page"} [coordinateSystem="Viewport"] + */ diff --git a/lib/remote-debugger-real-device.js b/lib/remote-debugger-real-device.js index 2e257f8c..0fb018ea 100644 --- a/lib/remote-debugger-real-device.js +++ b/lib/remote-debugger-real-device.js @@ -1,9 +1,19 @@ import RemoteDebugger from './remote-debugger'; import { RpcClientRealDevice } from './rpc'; +/** + * @typedef {Object} RemoteDebuggerRealDeviceOptions + * @property {string} udid Real device UDID + */ export default class RemoteDebuggerRealDevice extends RemoteDebugger { - constructor (opts = {}) { + /** @type {string} */ + udid; + + /** + * @param {RemoteDebuggerRealDeviceOptions & import('./remote-debugger').RemoteDebuggerOptions} opts + */ + constructor (opts) { super(opts); this.udid = opts.udid; @@ -11,6 +21,9 @@ export default class RemoteDebuggerRealDevice extends RemoteDebugger { this._skippedApps = ['lockdownd']; } + /** + * @override + */ initRpcClient () { this.rpcClient = new RpcClientRealDevice({ bundleId: this.bundleId, diff --git a/lib/remote-debugger.js b/lib/remote-debugger.js index 89af8824..7f7b0bf4 100644 --- a/lib/remote-debugger.js +++ b/lib/remote-debugger.js @@ -1,27 +1,28 @@ import { EventEmitter } from 'events'; -import log from './logger'; +import defaultLog from './logger'; import { RpcClientSimulator } from './rpc'; -import { checkParams, getModuleRoot } from './utils'; -import { mixins, events } from './mixins'; +import { getModuleProperties } from './utils'; +import * as connectMixins from './mixins/connect'; +import * as executeMixins from './mixins/execute'; +import * as messageHandlerMixins from './mixins/message-handlers'; +import * as navigationMixins from './mixins/navigate'; +import * as cookieMixins from './mixins/cookies'; +import * as screenshotMixins from './mixins/screenshot'; +import * as eventMixins from './mixins/events'; +import * as miscellaneousMixins from './mixins/misc'; import _ from 'lodash'; -import B from 'bluebird'; -import path from 'path'; import AsyncLock from 'async-lock'; -const REMOTE_DEBUGGER_PORT = 27753; -const SAFARI_BUNDLE_ID = 'com.apple.mobilesafari'; - +export const REMOTE_DEBUGGER_PORT = 27753; /* How many milliseconds to wait for webkit to return a response before timing out */ -const RPC_RESPONSE_TIMEOUT_MS = 5000; - -const PAGE_READY_TIMEOUT = 5000; +export const RPC_RESPONSE_TIMEOUT_MS = 5000; +const PAGE_READY_TIMEOUT_MS = 5000; +const { version: MODULE_VERSION } = getModuleProperties(); -const GARBAGE_COLLECT_TIMEOUT = 5000; - -class RemoteDebugger extends EventEmitter { +export class RemoteDebugger extends EventEmitter { // properties - /** @type {any[]|undefined} */ + /** @type {string[]|undefined} */ _skippedApps; /** @type {Record} */ _clientEventListeners; @@ -37,14 +38,16 @@ class RemoteDebugger extends EventEmitter { currentState; /** @type {boolean|undefined} */ connected; - /** @type {B} */ + /** @type {import('bluebird')} */ pageLoadDelay; - /** @type {B} */ + /** @type {import('bluebird')} */ navigationDelay; - /** @type {import('./rpc/rpc-client').default?} */ + /** @type {import('./rpc/rpc-client').RpcClient?} */ rpcClient; /** @type {string|undefined} */ pageLoadStrategy; + /** @type {import('@appium/types').AppiumLogger} */ + _log; // events /** @type {string} */ @@ -55,83 +58,46 @@ class RemoteDebugger extends EventEmitter { static EVENT_FRAMES_DETACHED; // methods - /** @type {() => Promise} */ - setConnectionKey; - /** @type {() => Promise} */ - disconnect; - /** @type {(currentUrl: string?, maxTries: number, ignoreAboutBlankUrl: boolean) => Promise>} */ - searchForApp; - /** @type {(appsDict:Record, currentUrl: string?, ignoreAboutBlankUrl: boolean) => import('./mixins/connect').AppPages?} */ - searchForPage; - /** @type {(timeoutMs?: number) => Promise} */ - checkPageIsReady; - /** @type {() => void} */ - cancelPageLoad; - /** @type {(dict: Record) => void} */ - updateAppsWithDict; - /** @type {(startPageLoadTimer?: import('@appium/support').timing.Timer) => Promise} */ - waitForDom; - /** @type {(command: string, override?: boolean) => Promise} */ - execute; - /** @type {(command: string, args?: any[], frames?: string[]) => Promise} */ - executeAtom; - /** @type {(atom: string, args?: any[], frames?: string[]) => Promise} */ - executeAtomAsync; - /** @type {(readyState: string) => boolean} */ - isPageLoadingCompleted; - /** @type {(currentUrl?: string, maxTries?: number, ignoreAboutBlankUrl?: boolean) => Promise} */ - selectApp; - /** @type {() => Promise} */ - connect; - /** @type {(appIdKey: string|number, pageIdKey: string|number, skipReadyCheck?: boolean) => Promise} */ - selectPage; - /** @type {(url: string) => Promise} */ - navToUrl; + setConnectionKey = connectMixins.setConnectionKey; + disconnect = connectMixins.disconnect; + searchForApp = connectMixins.searchForApp; + searchForPage = connectMixins.searchForPage; + checkPageIsReady = navigationMixins.checkPageIsReady; + cancelPageLoad = navigationMixins.cancelPageLoad; + waitForDom = navigationMixins.waitForDom; + execute = executeMixins.execute; + executeAtom = executeMixins.executeAtom; + executeAtomAsync = executeMixins.executeAtomAsync; + isPageLoadingCompleted = navigationMixins.isPageLoadingCompleted; + selectApp = connectMixins.selectApp; + connect = connectMixins.connect; + selectPage = connectMixins.selectPage; + navToUrl = navigationMixins.navToUrl; + getCookies = cookieMixins.getCookies; + setCookie = cookieMixins.setCookie; + deleteCookie = cookieMixins.deleteCookie; + captureScreenshot = screenshotMixins.captureScreenshot; + addClientEventListener = eventMixins.addClientEventListener; + removeClientEventListener = eventMixins.removeClientEventListener; + launchSafari = miscellaneousMixins.launchSafari; + startTimeline = miscellaneousMixins.startTimeline; + stopTimeline = miscellaneousMixins.stopTimeline; + startConsole = miscellaneousMixins.startConsole; + stopConsole = miscellaneousMixins.stopConsole; + startNetwork = miscellaneousMixins.startNetwork; + stopNetwork = miscellaneousMixins.stopNetwork; + overrideUserAgent = miscellaneousMixins.overrideUserAgent; + garbageCollect = miscellaneousMixins.garbageCollect; // Callbacks - /** @type {(err: Error?, appIdKey: string, pageDict: Record) => Promise} */ - onPageChange; - /** @type {(err: Error?, apps: Record) => Promise} */ - onConnectedApplicationList; - /** @type {(err: Error?, dict: Record) => Promise} */ - onAppConnect; - /** @type {(err: Error?, dict: Record) => void} */ - onAppDisconnect; - /** @type {(err: Error?, dict: Record) => Promise} */ - onAppUpdate; - /** @type {(err: Error?, drivers: Record) => void} */ - onConnectedDriverList; - /** @type {(err: Error?, state: Record) => void} */ - onCurrentState; - /** @type {(err: Error?, state: Record) => void} */ - frameDetached; - - /** - * @typedef {Object} RemoteDebuggerOptions - * @property {string} [bundleId] id of the app being connected to - * @property {string[]} [additionalBundleIds=[]] array of possible bundle - * ids that the inspector could return - * @property {string} [platformVersion] version of iOS - * @property {boolean} [isSafari=true] - * @property {boolean} [includeSafari=false] - * @property {boolean} [useNewSafari=false] for web inspector, whether this is a new Safari instance - * @property {number} [pageLoadMs] the time, in ms, that should be waited for page loading - * @property {string} [host] the remote debugger's host address - * @property {number} [port=REMOTE_DEBUGGER_PORT] the remote debugger port through which to communicate - * @property {string} [socketPath] - * @property {number} [pageReadyTimeout=PAGE_READY_TIMEOUT] - * @property {string} [remoteDebugProxy] - * @property {boolean} [garbageCollectOnExecute=false] - * @property {boolean} [logFullResponse=false] - * @property {boolean} [logAllCommunication=false] log plists sent and received from Web Inspector - * @property {boolean} [logAllCommunicationHexDump=false] log communication from Web Inspector as hex dump - * @property {number} [webInspectorMaxFrameLength] The maximum size in bytes of a single data - * frame in the device communication protocol - * @property {number} [socketChunkSize] size, in bytes, of chunks of data sent to - * Web Inspector (real device only) - * @property {boolean} [fullPageInitialization] - * @property {string} [pageLoadStrategy] - */ + onPageChange = messageHandlerMixins.onPageChange; + onConnectedApplicationList = messageHandlerMixins.onConnectedApplicationList; + onAppConnect = messageHandlerMixins.onAppConnect; + onAppDisconnect = messageHandlerMixins.onAppDisconnect; + onAppUpdate = messageHandlerMixins.onAppUpdate; + onConnectedDriverList = messageHandlerMixins.onConnectedDriverList; + onCurrentState = messageHandlerMixins.onCurrentState; + frameDetached = navigationMixins.frameDetached; /** * @param {RemoteDebuggerOptions} opts @@ -139,8 +105,9 @@ class RemoteDebugger extends EventEmitter { constructor (opts = {}) { super(); - // eslint-disable-next-line @typescript-eslint/no-var-requires - log.info(`Remote Debugger version ${require(path.resolve(getModuleRoot(), 'package.json')).version}`); + // @ts-ignore This is OK + this._log = opts.log ?? defaultLog; + this.log.info(`Remote Debugger version ${MODULE_VERSION}`); const { bundleId, @@ -153,7 +120,7 @@ class RemoteDebugger extends EventEmitter { host, port = REMOTE_DEBUGGER_PORT, socketPath, - pageReadyTimeout = PAGE_READY_TIMEOUT, + pageReadyTimeout = PAGE_READY_TIMEOUT_MS, remoteDebugProxy, garbageCollectOnExecute = false, logFullResponse = false, @@ -162,7 +129,7 @@ class RemoteDebugger extends EventEmitter { webInspectorMaxFrameLength, socketChunkSize, fullPageInitialization, - pageLoadStrategy + pageLoadStrategy, } = opts; this.bundleId = bundleId; @@ -172,7 +139,7 @@ class RemoteDebugger extends EventEmitter { this.includeSafari = includeSafari; this.useNewSafari = useNewSafari; this.pageLoadMs = pageLoadMs; - log.debug(`useNewSafari --> ${this.useNewSafari}`); + this.log.debug(`useNewSafari --> ${this.useNewSafari}`); this.garbageCollectOnExecute = garbageCollectOnExecute; @@ -197,6 +164,30 @@ class RemoteDebugger extends EventEmitter { this._lock = new AsyncLock(); } + /** + * @returns {import('@appium/types').AppiumLogger} + */ + get log() { + return this._log; + } + + /** + * @param {boolean} [checkConnected=false] + * @returns {import('./rpc/rpc-client').RpcClient} + */ + requireRpcClient(checkConnected = false) { + if (!this.rpcClient) { + throw new Error(`rpcClient is undefined. Has 'initRpcClient' been called before?`); + } + if (checkConnected && !this.rpcClient.isConnected) { + throw new Error('Remote debugger is not connected'); + } + return this.rpcClient; + } + + /** + * @returns {void} + */ setup () { // app handling configuration this.appDict = {}; @@ -210,8 +201,11 @@ class RemoteDebugger extends EventEmitter { this._clientEventListeners = {}; } + /** + * @returns {void} + */ teardown () { - log.debug('Cleaning up listeners'); + this.log.debug('Cleaning up listeners'); this.appDict = {}; this.appIdKey = null; @@ -224,6 +218,9 @@ class RemoteDebugger extends EventEmitter { this.removeAllListeners(RemoteDebugger.EVENT_DISCONNECT); } + /** + * @returns {void} + */ initRpcClient () { this.rpcClient = new RpcClientSimulator({ bundleId: this.bundleId, @@ -240,234 +237,65 @@ class RemoteDebugger extends EventEmitter { }); } + /** + * @returns {boolean} + */ get isConnected () { return !!this.rpcClient?.isConnected; } - async launchSafari () { - if (!this.rpcClient) { - throw new Error(`rpcClient is undefined. Has 'initRpcClient' been called before?`); - } - - await this.rpcClient.send('launchApplication', { - bundleId: SAFARI_BUNDLE_ID - }); - } - - async startTimeline (fn) { - if (!this.rpcClient) { - throw new Error(`rpcClient is undefined. Has 'initRpcClient' been called before?`); - } - - log.debug('Starting to record the timeline'); - this.rpcClient.on('Timeline.eventRecorded', fn); - return await this.rpcClient.send('Timeline.start', { - appIdKey: this.appIdKey, - pageIdKey: this.pageIdKey, - }); - } - - async stopTimeline () { - if (!this.rpcClient) { - throw new Error(`rpcClient is undefined. Has 'initRpcClient' been called before?`); - } - - log.debug('Stopping to record the timeline'); - await this.rpcClient.send('Timeline.stop', { - appIdKey: this.appIdKey, - pageIdKey: this.pageIdKey, - }); - } - - /* - * Keep track of the client event listeners so they can be removed + /** + * @param {boolean} allow */ - addClientEventListener (eventName, listener) { - if (!this.rpcClient) { - throw new Error(`rpcClient is undefined. Has 'initRpcClient' been called before?`); - } - - this._clientEventListeners[eventName] = this._clientEventListeners[eventName] || []; - this._clientEventListeners[eventName].push(listener); - this.rpcClient.on(eventName, listener); - } - - removeClientEventListener (eventName) { - if (!this.rpcClient) { - throw new Error(`rpcClient is undefined. Has 'initRpcClient' been called before?`); - } - - for (const listener of (this._clientEventListeners[eventName] || [])) { - this.rpcClient.off(eventName, listener); - } - } - - startConsole (listener) { - log.debug('Starting to listen for JavaScript console'); - this.addClientEventListener('Console.messageAdded', listener); - this.addClientEventListener('Console.messageRepeatCountUpdated', listener); - } - - stopConsole () { - log.debug('Stopping to listen for JavaScript console'); - this.removeClientEventListener('Console.messageAdded'); - this.removeClientEventListener('Console.messageRepeatCountUpdated'); - } - - startNetwork (listener) { - log.debug('Starting to listen for network events'); - this.addClientEventListener('NetworkEvent', listener); - } - - stopNetwork () { - log.debug('Stopping to listen for network events'); - this.removeClientEventListener('NetworkEvent'); - } - set allowNavigationWithoutReload (allow) { this._allowNavigationWithoutReload = allow; } + /** + * @returns {boolean} + */ get allowNavigationWithoutReload () { - return this._allowNavigationWithoutReload; - } - - // Potentially this does not work for mobile safari - async overrideUserAgent (value) { - log.debug('Setting overrideUserAgent'); - if (!this.rpcClient) { - throw new Error(`rpcClient is undefined. Has 'initRpcClient' been called before?`); - } - - return await this.rpcClient.send('Page.overrideUserAgent', { - appIdKey: this.appIdKey, - pageIdKey: this.pageIdKey, - value - }); + return !!this._allowNavigationWithoutReload; } /** - * Capture a rect of the page or by default the viewport - * @param {ScreenshotCaptureOptions} [opts={}] if rect is null capture the whole - * coordinate system else capture the rect in the given coordinateSystem - * @returns {Promise} a base64 encoded string of the screenshot + * @returns {string[]} */ - async captureScreenshot(opts = {}) { - const {rect = null, coordinateSystem = 'Viewport'} = opts; - log.debug('Capturing screenshot'); - if (!this.rpcClient) { - throw new Error(`rpcClient is undefined. Has 'initRpcClient' been called before?`); - } - - const arect = rect ?? /** @type {import('@appium/types').Rect} */ (await this.executeAtom( - 'execute_script', - ['return {x: 0, y: 0, width: window.innerWidth, height: window.innerHeight}', []] - )); - const response = await this.rpcClient.send('Page.snapshotRect', { - ...arect, - appIdKey: this.appIdKey, - pageIdKey: this.pageIdKey, - coordinateSystem, - }); - - if (response.error) { - throw new Error(response.error); - } - - return response.dataURL.replace(/^data:image\/png;base64,/, ''); - } - - async getCookies () { - log.debug('Getting cookies'); - if (!this.rpcClient) { - throw new Error(`rpcClient is undefined. Has 'initRpcClient' been called before?`); - } - - return await this.rpcClient.send('Page.getCookies', { - appIdKey: this.appIdKey, - pageIdKey: this.pageIdKey - }); - } - - async setCookie (cookie) { - log.debug('Setting cookie'); - if (!this.rpcClient) { - throw new Error(`rpcClient is undefined. Has 'initRpcClient' been called before?`); - } - - return await this.rpcClient.send('Page.setCookie', { - appIdKey: this.appIdKey, - pageIdKey: this.pageIdKey, - cookie - }); - } - - async deleteCookie (cookieName, url) { - log.debug(`Deleting cookie '${cookieName}' on '${url}'`); - if (!this.rpcClient) { - throw new Error(`rpcClient is undefined. Has 'initRpcClient' been called before?`); - } - - return await this.rpcClient.send('Page.deleteCookie', { - appIdKey: this.appIdKey, - pageIdKey: this.pageIdKey, - cookieName, - url, - }); - } - - async garbageCollect (timeoutMs = GARBAGE_COLLECT_TIMEOUT) { - log.debug(`Garbage collecting with ${timeoutMs}ms timeout`); - if (!this.rpcClient) { - throw new Error(`rpcClient is undefined. Has 'initRpcClient' been called before?`); - } - - try { - checkParams({appIdKey: this.appIdKey, pageIdKey: this.pageIdKey}); - } catch (err) { - log.debug(`Unable to collect garbage at this time`); - return; - } - - await B.resolve(this.rpcClient.send('Heap.gc', { - appIdKey: this.appIdKey, - pageIdKey: this.pageIdKey, - })).timeout(timeoutMs) - .then(function gcSuccess () { // eslint-disable-line promise/prefer-await-to-then - log.debug(`Garbage collection successful`); - }).catch(function gcError (err) { // eslint-disable-line promise/prefer-await-to-callbacks - if (err instanceof B.TimeoutError) { - log.debug(`Garbage collection timed out after ${timeoutMs}ms`); - } else { - log.debug(`Unable to collect garbage: ${err.message}`); - } - }); - } - - async useAppDictLock (fn) { - return await this._lock.acquire('appDict', fn); - } - get skippedApps () { - return this._skippedApps || []; + return this._skippedApps ?? []; } } -for (const [name, fn] of _.toPairs(mixins)) { - RemoteDebugger.prototype[name] = fn; -} - -for (const [name, event] of _.toPairs(events)) { +for (const [name, event] of _.toPairs(eventMixins.events)) { RemoteDebugger[name] = event; } export default RemoteDebugger; -export { - RemoteDebugger, REMOTE_DEBUGGER_PORT, RPC_RESPONSE_TIMEOUT_MS, -}; /** - * @typedef {Object} ScreenshotCaptureOptions - * @property {import('@appium/types').Rect | null} [rect=null] - * @property {"Viewport" | "Page"} [coordinateSystem="Viewport"] + * @typedef {Object} RemoteDebuggerOptions + * @property {string} [bundleId] id of the app being connected to + * @property {string[]} [additionalBundleIds=[]] array of possible bundle + * ids that the inspector could return + * @property {string} [platformVersion] version of iOS + * @property {boolean} [isSafari=true] + * @property {boolean} [includeSafari=false] + * @property {boolean} [useNewSafari=false] for web inspector, whether this is a new Safari instance + * @property {number} [pageLoadMs] the time, in ms, that should be waited for page loading + * @property {string} [host] the remote debugger's host address + * @property {number} [port=REMOTE_DEBUGGER_PORT] the remote debugger port through which to communicate + * @property {string} [socketPath] + * @property {number} [pageReadyTimeout=PAGE_READY_TIMEOUT] + * @property {string} [remoteDebugProxy] + * @property {boolean} [garbageCollectOnExecute=false] + * @property {boolean} [logFullResponse=false] + * @property {boolean} [logAllCommunication=false] log plists sent and received from Web Inspector + * @property {boolean} [logAllCommunicationHexDump=false] log communication from Web Inspector as hex dump + * @property {number} [webInspectorMaxFrameLength] The maximum size in bytes of a single data + * frame in the device communication protocol + * @property {number} [socketChunkSize] size, in bytes, of chunks of data sent to + * Web Inspector (real device only) + * @property {boolean} [fullPageInitialization] + * @property {string} [pageLoadStrategy] + * @property {import('@appium/types').AppiumLogger} [log] */ diff --git a/lib/rpc/rpc-client.js b/lib/rpc/rpc-client.js index 71bba437..6893683c 100644 --- a/lib/rpc/rpc-client.js +++ b/lib/rpc/rpc-client.js @@ -29,7 +29,7 @@ function isTargetBased (isSafari, platformVersion) { return isHighVersion; } -export default class RpcClient { +export class RpcClient { /** @type {RpcMessageHandler|undefined} */ messageHandler; @@ -696,3 +696,5 @@ export default class RpcClient { log.debug(`Script parsed: ${JSON.stringify(scriptInfo)}`); } } + +export default RpcClient; diff --git a/lib/utils.js b/lib/utils.js index c4cd79b3..f09920a2 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -3,6 +3,8 @@ import _ from 'lodash'; import B from 'bluebird'; import { errorFromMJSONWPStatusCode } from '@appium/base-driver'; import { util, node } from '@appium/support'; +import nodeFs from 'node:fs'; +import path from 'node:path'; const MODULE_NAME = 'appium-remote-debugger'; @@ -21,7 +23,7 @@ const ACCEPTED_PAGE_TYPES = [ 'WIRTypePage', // iOS 11.4 webview ]; -const RESPONSE_LOG_LENGTH = 100; +export const RESPONSE_LOG_LENGTH = 100; /** * @typedef {Object} DeferredPromise @@ -49,7 +51,7 @@ const RESPONSE_LOG_LENGTH = 100; * @param {Record} dict * @returns {[string, AppInfo]} */ -function appInfoFromDict (dict) { +export function appInfoFromDict (dict) { const id = dict.WIRApplicationIdentifierKey; const isProxy = _.isString(dict.WIRIsApplicationProxyKey) ? dict.WIRIsApplicationProxyKey.toLowerCase() === 'true' @@ -85,7 +87,7 @@ function appInfoFromDict (dict) { * Take a dictionary from the remote debugger and makes a more manageable * dictionary of pages available. */ -function pageArrayFromDict (pageDict) { +export function pageArrayFromDict (pageDict) { if (pageDict.id) { // the page is already translated, so wrap in an array and pass back return [pageDict]; @@ -112,7 +114,7 @@ function pageArrayFromDict (pageDict) { * @param {Record} appDict * @returns {string|undefined} */ -function getDebuggerAppKey (bundleId, appDict) { +export function getDebuggerAppKey (bundleId, appDict) { let appId; for (const [key, data] of _.toPairs(appDict)) { if (data.bundleId === bundleId) { @@ -141,29 +143,6 @@ function getDebuggerAppKey (bundleId, appDict) { return appId; } -/** - * - * @param {string} bundleId - * @param {Record} appDict - * @returns {string[]} - */ -function appIdsForBundle (bundleId, appDict) { - /** @type {Set} */ - const appIds = new Set(); - for (const [key, data] of _.toPairs(appDict)) { - if (data.bundleId.endsWith(bundleId)) { - appIds.add(key); - } - } - - // if nothing is found, try to get the generic app - if (appIds.size === 0 && bundleId !== WEB_CONTENT_BUNDLE_ID) { - return appIdsForBundle(WEB_CONTENT_BUNDLE_ID, appDict); - } - - return Array.from(appIds); -} - /** * Find app keys based on assigned bundleIds from appDict * When bundleIds includes a wildcard ('*'), returns all appKeys in appDict. @@ -171,7 +150,7 @@ function appIdsForBundle (bundleId, appDict) { * @param {Record} appDict * @returns {string[]} */ -function getPossibleDebuggerAppKeys(bundleIds, appDict) { +export function getPossibleDebuggerAppKeys(bundleIds, appDict) { if (bundleIds.includes(WILDCARD_BUNDLE_ID)) { log.debug('Skip checking bundle identifiers because the bundleIds includes a wildcard'); return _.uniq(Object.keys(appDict)); @@ -207,7 +186,7 @@ function getPossibleDebuggerAppKeys(bundleIds, appDict) { return Array.from(proxiedAppIds); } -function checkParams (params) { +export function checkParams (params) { // check if all parameters have a value const errors = _.toPairs(params) .filter(([, value]) => _.isNil(value)) @@ -217,7 +196,7 @@ function checkParams (params) { } } -function simpleStringify (value, multiline = false) { +export function simpleStringify (value, multiline = false) { if (!value) { return JSON.stringify(value); } @@ -234,7 +213,7 @@ function simpleStringify (value, multiline = false) { /** * @returns {DeferredPromise} */ -function deferredPromise () { +export function deferredPromise () { // http://bluebirdjs.com/docs/api/deferred-migration.html /** @type {(...args: any[]) => void} */ let resolve; @@ -253,7 +232,7 @@ function deferredPromise () { }; } -function convertResult (res) { +export function convertResult (res) { if (_.isUndefined(res)) { throw new Error(`Did not get OK result from remote debugger. Result was: ${_.truncate(simpleStringify(res), {length: RESPONSE_LOG_LENGTH})}`); } else if (_.isString(res)) { @@ -291,7 +270,7 @@ function convertResult (res) { * @returns {string} The full path to module root * @throws {Error} If the current module root folder cannot be determined */ -const getModuleRoot = _.memoize(function getModuleRoot () { +export const getModuleRoot = _.memoize(function getModuleRoot () { const root = node.getModuleRootSync(MODULE_NAME, __filename); if (!root) { throw new Error(`Cannot find the root folder of the ${MODULE_NAME} Node.js module`); @@ -299,8 +278,33 @@ const getModuleRoot = _.memoize(function getModuleRoot () { return root; }); -export { - appInfoFromDict, pageArrayFromDict, getDebuggerAppKey, - getPossibleDebuggerAppKeys, checkParams, simpleStringify, deferredPromise, - convertResult, RESPONSE_LOG_LENGTH, getModuleRoot, -}; +/** + * @returns {import('@appium/types').StringRecord} + */ +export function getModuleProperties() { + const fullPath = path.resolve(getModuleRoot(), 'package.json'); + return JSON.parse(nodeFs.readFileSync(fullPath, 'utf8')); +} + +/** + * + * @param {string} bundleId + * @param {Record} appDict + * @returns {string[]} + */ +function appIdsForBundle (bundleId, appDict) { + /** @type {Set} */ + const appIds = new Set(); + for (const [key, data] of _.toPairs(appDict)) { + if (data.bundleId.endsWith(bundleId)) { + appIds.add(key); + } + } + + // if nothing is found, try to get the generic app + if (appIds.size === 0 && bundleId !== WEB_CONTENT_BUNDLE_ID) { + return appIdsForBundle(WEB_CONTENT_BUNDLE_ID, appDict); + } + + return Array.from(appIds); +} diff --git a/test/unit/mixins/execute-specs.js b/test/unit/mixins/execute-specs.js index e1b2d01e..ae46058d 100644 --- a/test/unit/mixins/execute-specs.js +++ b/test/unit/mixins/execute-specs.js @@ -1,9 +1,7 @@ import { MOCHA_TIMEOUT } from '../../helpers/helpers'; -import exec from '../../../lib/mixins/execute'; +import { executeAtom, executeAtomAsync, callFunction, execute } from '../../../lib/mixins/execute'; import sinon from 'sinon'; -const { executeAtom, executeAtomAsync, callFunction, execute } = exec; - describe('execute', function () { this.timeout(MOCHA_TIMEOUT); @@ -19,12 +17,14 @@ describe('execute', function () { const ctx = { appIdKey: 'appId', pageIdKey: 'pageId', + log: {debug: () => {}}, execute, rpcClient: { isConnected: true, send: () => ({hello: 'world'}), }, }; + ctx.requireRpcClient = () => ctx.rpcClient; const res = await executeAtom.call(ctx, 'find_element', ['css selector', '#id', {ELEMENT: 'foo'}]); res.should.eql({hello: 'world'}); }); @@ -34,12 +34,14 @@ describe('execute', function () { const ctx = { appIdKey: 'appId', pageIdKey: 'pageId', + log: {debug: () => {}}, execute, rpcClient: { isConnected: true, send: () => ({result: {objectId: 'fake-object-id'}}), }, }; + ctx.requireRpcClient = () => ctx.rpcClient; const sendSpy = sinon.spy(ctx.rpcClient, 'send'); await executeAtomAsync.call(ctx, 'find_element', ['a', 'b', 'c'], ['frame-1'], ['frame-2']); const callArgs = sendSpy.firstCall.args; @@ -52,6 +54,7 @@ describe('execute', function () { const ctx = { appIdKey: 'fakeAppId', pageIdKey: 'fakePageId', + log: {debug: () => {}}, garbageCollectOnExecute: true, garbageCollect () { }, rpcClient: { @@ -63,6 +66,7 @@ describe('execute', function () { waitForDom () { }, pageLoading: true, }; + ctx.requireRpcClient = () => ctx.rpcClient; const sendSpy = sinon.spy(ctx.rpcClient, 'send'); await callFunction.call(ctx, 'fake-object-id', 'fake_function', ['a', 'b', 'c']); sendSpy.firstCall.args[0].should.equal('Runtime.callFunctionOn');