From ef277b3991c57ed34740d946bab1c3505674adec Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Sun, 4 Aug 2024 10:12:52 +0200 Subject: [PATCH] feat: Rewrite the RemoteDebugger class into typescript (#400) BREAKING CHANGE: The following class properties have been renamed (got a leading underscore) and made protected: skippedApps clientEventListeners appDict appIdKey pageIdKey connectedDrivers currentState pageLoadDelay rpcClient pageLoading navigatingToPage pageLoadStrategy bundleId additionalBundleIds platformVersion isSafari includeSafari useNewSafari pageLoadMs garbageCollectOnExecute host port socketPath remoteDebugProxy pageReadyTimeout logAllCommunication logAllCommunicationHexDump socketChunkSize webInspectorMaxFrameLength fullPageInitialization udid BREAKING CHANGE: The following class methods have been made private: searchForApp searchForPage BREAKING CHANGE: Remove the unused RPC_RESPONSE_TIMEOUT_MS export --- .github/workflows/functional-test.yml | 4 + lib/mixins/connect.js | 116 ++++---- lib/mixins/cookies.js | 20 +- lib/mixins/events.js | 49 +++- lib/mixins/execute.js | 35 ++- lib/mixins/message-handlers.js | 59 ++-- lib/mixins/misc.js | 68 ++--- lib/mixins/navigate.js | 69 +++-- lib/mixins/screenshot.js | 13 +- lib/remote-debugger-real-device.js | 39 --- lib/remote-debugger-real-device.ts | 28 ++ lib/remote-debugger.js | 299 --------------------- lib/remote-debugger.ts | 251 +++++++++++++++++ lib/types.ts | 45 +++- test/functional/safari-e2e-specs.js | 3 +- test/unit/mixins/connect-specs.js | 10 +- test/unit/mixins/execute-specs.js | 32 +-- test/unit/mixins/message-handlers-specs.js | 6 +- 18 files changed, 602 insertions(+), 544 deletions(-) delete mode 100644 lib/remote-debugger-real-device.js create mode 100644 lib/remote-debugger-real-device.ts delete mode 100644 lib/remote-debugger.js create mode 100644 lib/remote-debugger.ts diff --git a/.github/workflows/functional-test.yml b/.github/workflows/functional-test.yml index 4839f9b..3a37ad6 100644 --- a/.github/workflows/functional-test.yml +++ b/.github/workflows/functional-test.yml @@ -2,6 +2,10 @@ name: Functional Tests on: [pull_request] +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: e2e: strategy: diff --git a/lib/mixins/connect.js b/lib/mixins/connect.js index 163e2d7..2906c8c 100644 --- a/lib/mixins/connect.js +++ b/lib/mixins/connect.js @@ -66,19 +66,19 @@ export async function connect (timeout = APP_CONNECT_TIMEOUT_MS) { const timer = new timing.Timer().start(); this.log.debug(`Waiting up to ${timeout}ms for applications to be reported`); try { - await waitForCondition(() => !_.isEmpty(this.appDict), { + await waitForCondition(() => !_.isEmpty(this._appDict), { waitMs: timeout, intervalMs: APP_CONNECT_INTERVAL_MS, }); this.log.debug( - `Retrieved ${util.pluralize('application', _.size(this.appDict), true)} ` + + `Retrieved ${util.pluralize('application', _.size(this._appDict), true)} ` + `within ${timer.getDuration().asMilliSeconds.toFixed(0)}ms` ); } catch (err) { this.log.debug(`Timed out waiting for applications to be reported`); } } - return this.appDict || {}; + return this._appDict || {}; } catch (err) { this.log.error(`Error setting connection key: ${err.message}`); await this.disconnect(); @@ -92,8 +92,8 @@ export async function connect (timeout = APP_CONNECT_TIMEOUT_MS) { * @returns {Promise} */ export async function disconnect () { - if (this.rpcClient) { - await this.rpcClient.disconnect(); + if (this._rpcClient) { + await this._rpcClient.disconnect(); } this.emit(events.EVENT_DISCONNECT, true); this.teardown(); @@ -115,23 +115,23 @@ export async function selectApp (currentUrl = null, maxTries = SELECT_APP_RETRIE rpcClient.shouldCheckForTarget = false; try { const timer = new timing.Timer().start(); - if (!this.appDict || _.isEmpty(this.appDict)) { + if (!this._appDict || _.isEmpty(this._appDict)) { this.log.debug('No applications currently connected.'); return []; } - const { appIdKey } = await this.searchForApp(currentUrl, maxTries, ignoreAboutBlankUrl); - if (this.appIdKey !== appIdKey) { - this.log.debug(`Received altered app id, updating from '${this.appIdKey}' to '${appIdKey}'`); - this.appIdKey = appIdKey; + const { appIdKey } = await searchForApp.bind(this)(currentUrl, maxTries, ignoreAboutBlankUrl); + if (this._appIdKey !== appIdKey) { + this.log.debug(`Received altered app id, updating from '${this._appIdKey}' to '${appIdKey}'`); + this._appIdKey = appIdKey; } logApplicationDictionary.bind(this)(); // translate the dictionary into a useful form, and return to sender - this.log.debug(`Finally selecting app ${this.appIdKey}`); + this.log.debug(`Finally selecting app ${this._appIdKey}`); /** @type {import('../types').Page[]} */ const fullPageArray = []; - for (const [app, info] of _.toPairs(this.appDict)) { + for (const [app, info] of _.toPairs(this._appDict)) { if (!_.isArray(info.pageArray) || !info.isActive) { continue; } @@ -153,6 +153,32 @@ export async function selectApp (currentUrl = null, maxTries = SELECT_APP_RETRIE } } +/** + * + * @this {RemoteDebugger} + * @param {string|number} appIdKey + * @param {string|number} pageIdKey + * @param {boolean} [skipReadyCheck] + * @returns {Promise} + */ +export async function selectPage (appIdKey, pageIdKey, skipReadyCheck = false) { + this._appIdKey = _.startsWith(`${appIdKey}`, 'PID:') ? `${appIdKey}` : `PID:${appIdKey}`; + this._pageIdKey = pageIdKey; + + this.log.debug(`Selecting page '${pageIdKey}' on app '${this._appIdKey}' and forwarding socket setup`); + + const timer = new timing.Timer().start(); + + await this.requireRpcClient().selectPage(this._appIdKey, pageIdKey); + + if (!skipReadyCheck && !await this.checkPageIsReady()) { + await this.waitForDom(); + } + + this.log.debug(`Selected page after ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`); +} + + /** * * @this {RemoteDebugger} @@ -161,10 +187,10 @@ export async function selectApp (currentUrl = null, maxTries = SELECT_APP_RETRIE * @param {boolean} ignoreAboutBlankUrl * @returns {Promise} */ -export async function searchForApp (currentUrl, maxTries, ignoreAboutBlankUrl) { - const bundleIds = this.includeSafari && !this.isSafari - ? [this.bundleId, ...this.additionalBundleIds, SAFARI_BUNDLE_ID] - : [this.bundleId, ...this.additionalBundleIds]; +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; return /** @type {import('../types').AppPage} */ (await retryInterval(maxTries, SELECT_APP_RETRY_SLEEP_MS, async () => { logApplicationDictionary.bind(this)(); @@ -172,7 +198,7 @@ export async function searchForApp (currentUrl, maxTries, ignoreAboutBlankUrl) { 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) { + if (!this._appDict[attemptedAppIdKey].isActive) { this.log.debug(`Skipping app '${attemptedAppIdKey}' because it is not active`); continue; } @@ -190,12 +216,12 @@ export async function searchForApp (currentUrl, maxTries, ignoreAboutBlankUrl) { } // save the page array for this app - this.appDict[appIdKey].pageArray = pageArrayFromDict(pageDict); + this._appDict[appIdKey].pageArray = pageArrayFromDict(pageDict); // if we are looking for a particular url, make sure we // have the right page. Ignore empty or undefined urls. // Ignore about:blank if requested. - const result = this.searchForPage(this.appDict, currentUrl, ignoreAboutBlankUrl); + const result = searchForPage.bind(this)(this._appDict, currentUrl, ignoreAboutBlankUrl); if (result) { return result; } @@ -225,7 +251,7 @@ export async function searchForApp (currentUrl, maxTries, ignoreAboutBlankUrl) { * @param {boolean} [ignoreAboutBlankUrl] * @returns {import('../types').AppPage?} */ -export function searchForPage (appsDict, currentUrl = null, ignoreAboutBlankUrl = false) { +function searchForPage (appsDict, currentUrl = null, ignoreAboutBlankUrl = false) { for (const appDict of _.values(appsDict)) { if (!appDict || !appDict.isActive || !appDict.pageArray || _.isEmpty(appDict.pageArray)) { continue; @@ -244,39 +270,13 @@ export function searchForPage (appsDict, currentUrl = null, ignoreAboutBlankUrl return null; } -/** - * - * @this {RemoteDebugger} - * @param {string|number} appIdKey - * @param {string|number} pageIdKey - * @param {boolean} [skipReadyCheck] - * @returns {Promise} - */ -export async function selectPage (appIdKey, pageIdKey, skipReadyCheck = false) { - this.appIdKey = _.startsWith(`${appIdKey}`, 'PID:') ? `${appIdKey}` : `PID:${appIdKey}`; - this.pageIdKey = pageIdKey; - - this.log.debug(`Selecting page '${pageIdKey}' on app '${this.appIdKey}' and forwarding socket setup`); - - const timer = new timing.Timer().start(); - - await this.requireRpcClient().selectPage(this.appIdKey, pageIdKey); - - // make sure everything is ready to go - if (!skipReadyCheck && !await this.checkPageIsReady()) { - await this.waitForDom(); - } - - this.log.debug(`Selected page after ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`); -} - /** * @this {RemoteDebugger} * @returns {void} */ function logApplicationDictionary () { this.log.debug('Current applications available:'); - for (const [app, info] of _.toPairs(this.appDict)) { + for (const [app, info] of _.toPairs(this._appDict)) { this.log.debug(` Application: "${app}"`); for (const [key, value] of _.toPairs(info)) { if (key === 'pageArray' && Array.isArray(value) && value.length) { @@ -307,7 +307,7 @@ function logApplicationDictionary () { export function getPossibleDebuggerAppKeys(bundleIds) { if (bundleIds.includes(WILDCARD_BUNDLE_ID)) { this.log.debug('Skip checking bundle identifiers because the bundleIds includes a wildcard'); - return _.uniq(Object.keys(this.appDict)); + return _.uniq(Object.keys(this._appDict)); } // go through the possible bundle identifiers @@ -324,10 +324,10 @@ export function getPossibleDebuggerAppKeys(bundleIds) { const proxiedAppIds = new Set(); for (const bundleId of possibleBundleIds) { // now we need to determine if we should pick a proxy for this instead - for (const appId of appIdsForBundle(bundleId, this.appDict)) { + for (const appId of appIdsForBundle(bundleId, this._appDict)) { proxiedAppIds.add(appId); this.log.debug(`Found app id key '${appId}' for bundle '${bundleId}'`); - for (const [key, data] of _.toPairs(this.appDict)) { + for (const [key, data] of _.toPairs(this._appDict)) { if (data.isProxy && data.hostId === appId) { this.log.debug( `Found separate bundleId '${data.bundleId}' ` + @@ -343,5 +343,19 @@ export function getPossibleDebuggerAppKeys(bundleIds) { } /** - * @typedef {import('../remote-debugger').RemoteDebugger} RemoteDebugger + * @typedef {Object} HasConnectionRelatedProperties + * @property {string | null | undefined} _appIdKey + * @property {string | number | null | undefined} _pageIdKey + * @property {import('../types').AppDict} _appDict + * @property {string | undefined} _bundleId + * @property {import('../rpc/rpc-client').RpcClient | undefined} _rpcClient + * @property {boolean} _includeSafari + * @property {boolean} _isSafari + * @property {string[]} _additionalBundleIds + * @property {(this: RemoteDebugger, timeoutMs?: number | undefined) => Promise} checkPageIsReady: + * @property {(this: RemoteDebugger, startPageLoadTimer?: timing.Timer | null | undefined) => Promise} waitForDom: + */ + +/** + * @typedef {import('../remote-debugger').RemoteDebugger & HasConnectionRelatedProperties} RemoteDebugger */ diff --git a/lib/mixins/cookies.js b/lib/mixins/cookies.js index 04682b2..dc9c58b 100644 --- a/lib/mixins/cookies.js +++ b/lib/mixins/cookies.js @@ -7,8 +7,8 @@ export async function getCookies () { this.log.debug('Getting cookies'); return await this.requireRpcClient().send('Page.getCookies', { - appIdKey: this.appIdKey, - pageIdKey: this.pageIdKey + appIdKey: this._appIdKey, + pageIdKey: this._pageIdKey }); } @@ -21,8 +21,8 @@ export async function getCookies () { export async function setCookie (cookie) { this.log.debug('Setting cookie'); return await this.requireRpcClient().send('Page.setCookie', { - appIdKey: this.appIdKey, - pageIdKey: this.pageIdKey, + appIdKey: this._appIdKey, + pageIdKey: this._pageIdKey, cookie }); } @@ -37,13 +37,19 @@ export async function setCookie (cookie) { 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, + appIdKey: this._appIdKey, + pageIdKey: this._pageIdKey, cookieName, url, }); } /** - * @typedef {import('../remote-debugger').RemoteDebugger} RemoteDebugger + * @typedef {Object} HasCookiesRelatedProperties + * @property {string | null | undefined} _appIdKey + * @property {string | number | null | undefined} _pageIdKey + */ + +/** + * @typedef {import('../remote-debugger').RemoteDebugger & HasCookiesRelatedProperties} RemoteDebugger */ diff --git a/lib/mixins/events.js b/lib/mixins/events.js index adaf5f5..d60c1c0 100644 --- a/lib/mixins/events.js +++ b/lib/mixins/events.js @@ -10,7 +10,7 @@ export const events = { * * @this {RemoteDebugger} * @param {string} eventName - * @param {(event: import('@appium/types').StringRecord) => any} listener + * @param {import('../types').EventListener} listener * @returns {void} */ export function addClientEventListener (eventName, listener) { @@ -30,8 +30,53 @@ export function removeClientEventListener (eventName) { } } +/** + * @this {RemoteDebugger} + * @param {import('../types').EventListener} 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 {RemoteDebugger} + * @returns {void} + */ +export function stopConsole () { + this.log.debug('Stopping to listen for JavaScript console'); + this.removeClientEventListener('Console.messageAdded'); + this.removeClientEventListener('Console.messageRepeatCountUpdated'); +} + +/** + * @this {RemoteDebugger} + * @param {import('../types').EventListener} listener + * @returns {void} + */ +export function startNetwork (listener) { + this.log.debug('Starting to listen for network events'); + this.addClientEventListener('NetworkEvent', listener); +} + +/** + * @this {RemoteDebugger} + * @returns {void} + */ +export function stopNetwork () { + this.log.debug('Stopping to listen for network events'); + this.removeClientEventListener('NetworkEvent'); +} + export default events; /** - * @typedef {import('../remote-debugger').RemoteDebugger} RemoteDebugger + * @typedef {Object} HasEventsRelatedProperties + * @property {import('@appium/types').StringRecord} _clientEventListeners: + */ + +/** + * @typedef {import('../remote-debugger').RemoteDebugger & HasEventsRelatedProperties} RemoteDebugger */ diff --git a/lib/mixins/execute.js b/lib/mixins/execute.js index aa3a7d2..38a1b86 100644 --- a/lib/mixins/execute.js +++ b/lib/mixins/execute.js @@ -36,8 +36,8 @@ export async function executeAtom (atom, args = [], frames = []) { export async function executeAtomAsync (atom, args = [], frames = []) { // helper to send directly to the web inspector const evaluate = async (method, opts) => await this.requireRpcClient(true).send(method, Object.assign({ - appIdKey: this.appIdKey, - pageIdKey: this.pageIdKey, + appIdKey: this._appIdKey, + pageIdKey: this._pageIdKey, returnByValue: false, }, opts)); @@ -125,19 +125,19 @@ export async function executeAtomAsync (atom, args = [], frames = []) { */ export async function execute (command, override) { // if the page is not loaded yet, wait for it - if (this.pageLoading && !override) { + if (this._pageLoading && !override) { this.log.debug('Trying to execute but page is not loaded.'); await this.waitForDom(); } - if (_.isNil(this.appIdKey)) { + if (_.isNil(this._appIdKey)) { throw new Error('Missing parameter: appIdKey. Is the target web application still alive?'); } - if (_.isNil(this.pageIdKey)) { + if (_.isNil(this._pageIdKey)) { throw new Error('Missing parameter: pageIdKey. Is the target web page still alive?'); } - if (this.garbageCollectOnExecute) { + if (this._garbageCollectOnExecute) { await this.garbageCollect(); } @@ -145,8 +145,8 @@ export async function execute (command, override) { const res = await this.requireRpcClient(true).send('Runtime.evaluate', { expression: command, returnByValue: true, - appIdKey: this.appIdKey, - pageIdKey: this.pageIdKey, + appIdKey: this._appIdKey, + pageIdKey: this._pageIdKey, }); return convertResult(res); } @@ -158,9 +158,9 @@ export async function execute (command, override) { * @param {any[]} [args] */ export async function callFunction (objectId, fn, args) { - checkParams({appIdKey: this.appIdKey, pageIdKey: this.pageIdKey}); + checkParams({appIdKey: this._appIdKey, pageIdKey: this._pageIdKey}); - if (this.garbageCollectOnExecute) { + if (this._garbageCollectOnExecute) { await this.garbageCollect(); } @@ -170,13 +170,22 @@ export async function callFunction (objectId, fn, args) { functionDeclaration: fn, arguments: args, returnByValue: true, - appIdKey: this.appIdKey, - pageIdKey: this.pageIdKey, + appIdKey: this._appIdKey, + pageIdKey: this._pageIdKey, }); return convertResult(res); } /** - * @typedef {import('../remote-debugger').RemoteDebugger} RemoteDebugger + * @typedef {Object} HasExecutionRelatedProperties + * @property {string | null | undefined} _appIdKey + * @property {string | number | null | undefined} _pageIdKey + * @property {boolean} _pageLoading + * @property {boolean} _garbageCollectOnExecute + * @property {(this: RemoteDebugger, startPageLoadTimer?: timing.Timer | null | undefined) => Promise} waitForDom: + */ + +/** + * @typedef {import('../remote-debugger').RemoteDebugger & HasExecutionRelatedProperties} RemoteDebugger */ diff --git a/lib/mixins/message-handlers.js b/lib/mixins/message-handlers.js index a7ff25f..ef085af 100644 --- a/lib/mixins/message-handlers.js +++ b/lib/mixins/message-handlers.js @@ -26,8 +26,8 @@ export async function onPageChange (err, appIdKey, pageDict) { const currentPages = pageArrayFromDict(pageDict); // save the page dict for this app - if (this.appDict[appIdKey]) { - const previousPages = this.appDict[appIdKey].pageArray; + if (this._appDict[appIdKey]) { + const previousPages = this._appDict[appIdKey].pageArray; // we have a pre-existing pageDict if (previousPages && _.isEqual(previousPages, currentPages)) { this.log.debug( @@ -37,7 +37,7 @@ export async function onPageChange (err, appIdKey, pageDict) { return; } // keep track of the page dictionary - this.appDict[appIdKey].pageArray = currentPages; + this._appDict[appIdKey].pageArray = currentPages; this.log.debug( `Pages changed for ${appIdKey}: ${JSON.stringify(previousPages)} -> ${JSON.stringify(currentPages)}` ); @@ -76,22 +76,21 @@ export async function onAppConnect (err, dict) { export function onAppDisconnect (err, dict) { const appIdKey = dict.WIRApplicationIdentifierKey; this.log.debug(`Application '${appIdKey}' disconnected. Removing from app dictionary.`); - this.log.debug(`Current app is '${this.appIdKey}'`); + this.log.debug(`Current app is '${this._appIdKey}'`); // get rid of the entry in our app dictionary, // since it is no longer available - delete this.appDict[appIdKey]; + delete this._appDict[appIdKey]; // if the disconnected app is the one we are connected to, try to find another - if (this.appIdKey === appIdKey) { + if (this._appIdKey === appIdKey) { this.log.debug(`No longer have app id. Attempting to find new one.`); - this.appIdKey = getDebuggerAppKey.bind(this)(/** @type {string} */ (this.bundleId)); + this._appIdKey = getDebuggerAppKey.bind(this)(/** @type {string} */ (this._bundleId)); } - if (!this.appDict) { + if (!this._appDict) { // this means we no longer have any apps. what the what? this.log.debug('Main app disconnected. Disconnecting altogether.'); - this.connected = false; this.emit(events.EVENT_DISCONNECT, true); } } @@ -115,8 +114,8 @@ export async function onAppUpdate (err, dict) { * @returns {void} */ export function onConnectedDriverList (err, drivers) { - this.connectedDrivers = drivers.WIRDriverDictionaryKey; - this.log.debug(`Received connected driver list: ${JSON.stringify(this.connectedDrivers)}`); + this._connectedDrivers = drivers.WIRDriverDictionaryKey; + this.log.debug(`Received connected driver list: ${JSON.stringify(this._connectedDrivers)}`); } /** @@ -126,10 +125,10 @@ export function onConnectedDriverList (err, drivers) { * @returns {void} */ export function onCurrentState (err, state) { - this.currentState = state.WIRAutomationAvailabilityKey; + this._currentState = state.WIRAutomationAvailabilityKey; // This state changes when 'Remote Automation' in 'Settings app' > 'Safari' > 'Advanced' > 'Remote Automation' changes // WIRAutomationAvailabilityAvailable or WIRAutomationAvailabilityNotAvailable - this.log.debug(`Received connected automation availability state: ${JSON.stringify(this.currentState)}`); + this.log.debug(`Received connected automation availability state: ${JSON.stringify(this._currentState)}`); } /** @@ -147,13 +146,13 @@ export async function onConnectedApplicationList (err, apps) { let newDict = {}; for (const dict of _.values(apps)) { const [id, entry] = appInfoFromDict(dict); - if (this.skippedApps.includes(entry.name)) { + if (this._skippedApps.includes(entry.name)) { continue; } newDict[id] = entry; } // update the object's list of apps - _.defaults(this.appDict, newDict); + _.defaults(this._appDict, newDict); } /** @@ -165,17 +164,17 @@ export async function onConnectedApplicationList (err, apps) { function updateAppsWithDict (dict) { // get the dictionary entry into a nice form, and add it to the // application dictionary - this.appDict ??= {}; + this._appDict ??= {}; const [id, entry] = appInfoFromDict(dict); - if (this.appDict[id]) { + if (this._appDict[id]) { // preserve the page dictionary for this entry - entry.pageArray = this.appDict[id].pageArray; + entry.pageArray = this._appDict[id].pageArray; } - this.appDict[id] = entry; + this._appDict[id] = entry; // try to get the app id from our connected apps - if (!this.appIdKey) { - this.appIdKey = getDebuggerAppKey.bind(this)(/** @type {string} */ (this.bundleId)); + if (!this._appIdKey) { + this._appIdKey = getDebuggerAppKey.bind(this)(/** @type {string} */ (this._bundleId)); } } @@ -189,7 +188,7 @@ function updateAppsWithDict (dict) { */ export function getDebuggerAppKey (bundleId) { let appId; - for (const [key, data] of _.toPairs(this.appDict)) { + for (const [key, data] of _.toPairs(this._appDict)) { if (data.bundleId === bundleId) { appId = key; break; @@ -199,7 +198,7 @@ export function getDebuggerAppKey (bundleId) { if (appId) { this.log.debug(`Found app id key '${appId}' for bundle '${bundleId}'`); let proxyAppId; - for (const [key, data] of _.toPairs(this.appDict)) { + for (const [key, data] of _.toPairs(this._appDict)) { if (data.isProxy && data.hostId === appId) { this.log.debug(`Found separate bundleId '${data.bundleId}' ` + `acting as proxy for '${bundleId}', with app id '${key}'`); @@ -217,5 +216,17 @@ export function getDebuggerAppKey (bundleId) { } /** - * @typedef {import('../remote-debugger').RemoteDebugger} RemoteDebugger + * @typedef {Object} HasMessageHandlersRelatedProperties + * @property {string | null | undefined} _appIdKey + * @property {string | number | null | undefined} _pageIdKey + * @property {import('../types').AppDict} _appDict + * @property {boolean} _navigatingToPage + * @property {string | undefined} _currentState + * @property {string | undefined} _bundleId + * @property {string[] | undefined} _connectedDrivers + * @property {string[]} _skippedApps + */ + +/** + * @typedef {import('../remote-debugger').RemoteDebugger & HasMessageHandlersRelatedProperties} RemoteDebugger */ diff --git a/lib/mixins/misc.js b/lib/mixins/misc.js index e0676ce..f8d63a3 100644 --- a/lib/mixins/misc.js +++ b/lib/mixins/misc.js @@ -16,15 +16,15 @@ export async function launchSafari () { /** * @this {RemoteDebugger} - * @param {(event: import('@appium/types').StringRecord) => any} fn + * @param {import('../types').EventListener} 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, + appIdKey: this._appIdKey, + pageIdKey: this._pageIdKey, }); } @@ -35,51 +35,11 @@ export async function startTimeline (fn) { export async function stopTimeline () { this.log.debug('Stopping to record the timeline'); await this.requireRpcClient().send('Timeline.stop', { - appIdKey: this.appIdKey, - pageIdKey: this.pageIdKey, + appIdKey: this._appIdKey, + pageIdKey: this._pageIdKey, }); } -/** - * @this {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 {RemoteDebugger} - * @returns {void} - */ -export function stopConsole () { - this.log.debug('Stopping to listen for JavaScript console'); - this.removeClientEventListener('Console.messageAdded'); - this.removeClientEventListener('Console.messageRepeatCountUpdated'); -} - -/** - * @this {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 {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 {RemoteDebugger} @@ -89,8 +49,8 @@ export function stopNetwork () { export async function overrideUserAgent (value) { this.log.debug('Setting overrideUserAgent'); return await this.requireRpcClient().send('Page.overrideUserAgent', { - appIdKey: this.appIdKey, - pageIdKey: this.pageIdKey, + appIdKey: this._appIdKey, + pageIdKey: this._pageIdKey, value }); } @@ -104,7 +64,7 @@ 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}); + checkParams({appIdKey: this._appIdKey, pageIdKey: this._pageIdKey}); } catch (err) { this.log.debug(`Unable to collect garbage at this time`); return; @@ -113,8 +73,8 @@ export async function garbageCollect (timeoutMs = GARBAGE_COLLECT_TIMEOUT_MS) { try { await B.resolve(this.requireRpcClient().send( 'Heap.gc', { - appIdKey: this.appIdKey, - pageIdKey: this.pageIdKey, + appIdKey: this._appIdKey, + pageIdKey: this._pageIdKey, }) ).timeout(timeoutMs); this.log.debug(`Garbage collection successful`); @@ -128,5 +88,11 @@ export async function garbageCollect (timeoutMs = GARBAGE_COLLECT_TIMEOUT_MS) { } /** - * @typedef {import('../remote-debugger').RemoteDebugger} RemoteDebugger + * @typedef {Object} HasMiscRelatedProperties + * @property {string | null | undefined} _appIdKey + * @property {string | number | null | undefined} _pageIdKey + */ + +/** + * @typedef {import('../remote-debugger').RemoteDebugger & HasMiscRelatedProperties} RemoteDebugger */ diff --git a/lib/mixins/navigate.js b/lib/mixins/navigate.js index 93f71b2..4fbdb25 100644 --- a/lib/mixins/navigate.js +++ b/lib/mixins/navigate.js @@ -29,14 +29,14 @@ export function frameDetached () { } /** - * @this {RemoteDebugger} + * @this {RemoteDebugger & HasNavigationRelatedProperties} * @returns {void} */ export function cancelPageLoad () { this.log.debug('Unregistering from page readiness notifications'); - this.pageLoading = false; - if (this.pageLoadDelay) { - this.pageLoadDelay.cancel(); + this._pageLoading = false; + if (this._pageLoadDelay) { + this._pageLoadDelay.cancel(); } } @@ -49,7 +49,7 @@ export function cancelPageLoad () { * @returns {boolean} */ export function isPageLoadingCompleted (readyState) { - const _pageLoadStrategy = _.toLower(this.pageLoadStrategy); + const _pageLoadStrategy = _.toLower(this._pageLoadStrategy); if (_pageLoadStrategy === PAGE_LOAD_STRATEGY.NONE) { return true; } @@ -70,15 +70,15 @@ export function isPageLoadingCompleted (readyState) { */ export async function waitForDom (startPageLoadTimer) { this.log.debug('Waiting for page readiness'); - const readinessTimeoutMs = this.pageLoadMs || DEFAULT_PAGE_READINESS_TIMEOUT_MS; + const readinessTimeoutMs = this._pageLoadMs || DEFAULT_PAGE_READINESS_TIMEOUT_MS; if (!_.isFunction(startPageLoadTimer?.getDuration)) { this.log.debug(`Page load timer not a timer. Creating new timer`); startPageLoadTimer = new timing.Timer().start(); } let isPageLoading = true; - this.pageLoading = true; - this.pageLoadDelay = util.cancellableDelay(readinessTimeoutMs); + this._pageLoading = true; + this._pageLoadDelay = util.cancellableDelay(readinessTimeoutMs); /** @type {B} */ const pageReadinessPromise = B.resolve((async () => { let retry = 0; @@ -93,7 +93,7 @@ export 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) { + if (!this._appIdKey) { this.log.debug('Not connected to an application. Ignoring page readiess check'); return; } @@ -119,7 +119,7 @@ export async function waitForDom (startPageLoadTimer) { /** @type {B} */ const cancellationPromise = B.resolve((async () => { try { - await this.pageLoadDelay; + await this._pageLoadDelay; } catch (ign) {} })()); @@ -127,8 +127,8 @@ export async function waitForDom (startPageLoadTimer) { await B.any([cancellationPromise, pageReadinessPromise]); } finally { isPageLoading = false; - this.pageLoading = false; - this.pageLoadDelay = B.resolve(); + this._pageLoading = false; + this._pageLoadDelay = B.resolve(); } } @@ -138,20 +138,20 @@ export async function waitForDom (startPageLoadTimer) { * @returns {Promise} */ export async function checkPageIsReady (timeoutMs) { - checkParams({appIdKey: this.appIdKey}); + checkParams({appIdKey: this._appIdKey}); const readyCmd = 'document.readyState;'; try { const readyState = await B.resolve(this.execute(readyCmd, true)) - .timeout(timeoutMs ?? this.pageReadyTimeout); + .timeout(timeoutMs ?? this._pageReadyTimeout); this.log.debug(`Document readyState is '${readyState}'. ` + - `The pageLoadStrategy is '${this.pageLoadStrategy || PAGE_LOAD_STRATEGY.NORMAL}'`); + `The pageLoadStrategy is '${this._pageLoadStrategy || PAGE_LOAD_STRATEGY.NORMAL}'`); return this.isPageLoadingCompleted(readyState); } catch (err) { if (!(err instanceof B.TimeoutError)) { throw err; } - this.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; } } @@ -162,7 +162,7 @@ export async function checkPageIsReady (timeoutMs) { * @returns {Promise} */ export async function navToUrl (url) { - checkParams({appIdKey: this.appIdKey, pageIdKey: this.pageIdKey}); + checkParams({appIdKey: this._appIdKey, pageIdKey: this._pageIdKey}); const rpcClient = this.requireRpcClient(); try { @@ -173,15 +173,15 @@ export async function navToUrl (url) { this._navigatingToPage = true; this.log.debug(`Navigating to new URL: '${url}'`); - const readinessTimeoutMs = this.pageLoadMs || DEFAULT_PAGE_READINESS_TIMEOUT_MS; + const readinessTimeoutMs = this._pageLoadMs || DEFAULT_PAGE_READINESS_TIMEOUT_MS; /** @type {(() => void)|undefined} */ let onPageLoaded; /** @type {(() => void)|undefined} */ let onTargetProvisioned; /** @type {NodeJS.Timeout|undefined|null} */ let onPageLoadedTimeout; - this.pageLoadDelay = util.cancellableDelay(readinessTimeoutMs); - this.pageLoading = true; + this._pageLoadDelay = util.cancellableDelay(readinessTimeoutMs); + this._pageLoading = true; let isPageLoading = true; let didPageFinishLoad = false; const start = new timing.Timer().start(); @@ -236,24 +236,24 @@ export async function navToUrl (url) { rpcClient.send('Page.navigate', { url, - appIdKey: this.appIdKey, - pageIdKey: this.pageIdKey, + appIdKey: this._appIdKey, + pageIdKey: this._pageIdKey, }); }); /** @type {B} */ const cancellationPromise = B.resolve((async () => { try { - await this.pageLoadDelay; + await this._pageLoadDelay; } catch (ign) {} })()); try { await B.any([cancellationPromise, pageReadinessPromise]); } finally { - this.pageLoading = false; + this._pageLoading = false; isPageLoading = false; this._navigatingToPage = false; - this.pageLoadDelay = B.resolve(); + this._pageLoadDelay = B.resolve(); if (onPageLoadedTimeout && pageReadinessPromise.isFulfilled()) { clearTimeout(onPageLoadedTimeout); onPageLoadedTimeout = null; @@ -270,8 +270,8 @@ export async function navToUrl (url) { // get notified when navigating to a local page try { await B.resolve(rpcClient.send('Console.enable', { - appIdKey: this.appIdKey, - pageIdKey: this.pageIdKey, + appIdKey: this._appIdKey, + pageIdKey: this._pageIdKey, }, didPageFinishLoad)).timeout(CONSOLE_ENABLEMENT_TIMEOUT_MS); } catch (err) { if (err instanceof B.TimeoutError) { @@ -283,5 +283,18 @@ export async function navToUrl (url) { } /** - * @typedef {import('../remote-debugger').RemoteDebugger} RemoteDebugger + * @typedef {Object} HasNavigationRelatedProperties + * @property {boolean} _pageLoading + * @property {B} _pageLoadDelay + * @property {string | undefined} _pageLoadStrategy + * @property {number | undefined} _pageLoadMs + * @property {string | null | undefined} _appIdKey + * @property {string | number | null | undefined} _pageIdKey + * @property {boolean} _navigatingToPage + * @property {number} _pageReadyTimeout + * @property {(this: RemoteDebugger, command: string, override?: boolean | undefined) => Promise} execute: + */ + +/** + * @typedef {import('../remote-debugger').RemoteDebugger & HasNavigationRelatedProperties} RemoteDebugger */ diff --git a/lib/mixins/screenshot.js b/lib/mixins/screenshot.js index 5c13caa..d36ba25 100644 --- a/lib/mixins/screenshot.js +++ b/lib/mixins/screenshot.js @@ -15,8 +15,8 @@ export async function captureScreenshot(opts = {}) { )); const response = await this.requireRpcClient().send('Page.snapshotRect', { ...arect, - appIdKey: this.appIdKey, - pageIdKey: this.pageIdKey, + appIdKey: this._appIdKey, + pageIdKey: this._pageIdKey, coordinateSystem, }); @@ -34,5 +34,12 @@ export async function captureScreenshot(opts = {}) { */ /** - * @typedef {import('../remote-debugger').RemoteDebugger} RemoteDebugger + * @typedef {Object} HasScreenshotRelatedProperties + * @property {string | null | undefined} _appIdKey + * @property {string | number | null | undefined} _pageIdKey + * @property {(this: RemoteDebugger, atom: string, args?: any[], frames?: string[]) => Promise} executeAtom: + */ + +/** + * @typedef {import('../remote-debugger').RemoteDebugger & HasScreenshotRelatedProperties} RemoteDebugger */ diff --git a/lib/remote-debugger-real-device.js b/lib/remote-debugger-real-device.js deleted file mode 100644 index 0914ca9..0000000 --- a/lib/remote-debugger-real-device.js +++ /dev/null @@ -1,39 +0,0 @@ -import RemoteDebugger from './remote-debugger'; -import { RpcClientRealDevice } from './rpc'; - -/** - * @typedef {Object} RemoteDebuggerRealDeviceOptions - * @property {string} udid Real device UDID - */ - -export default class RemoteDebuggerRealDevice extends RemoteDebugger { - /** @type {string} */ - udid; - - /** - * @param {RemoteDebuggerRealDeviceOptions & import('./remote-debugger').RemoteDebuggerOptions} opts - */ - constructor (opts) { - super(opts); - - this.udid = opts.udid; - - this._skippedApps = ['lockdownd']; - } - - /** - * @override - */ - initRpcClient () { - this.rpcClient = new RpcClientRealDevice({ - bundleId: this.bundleId, - platformVersion: this.platformVersion, - isSafari: this.isSafari, - logAllCommunication: this.logAllCommunication, - logAllCommunicationHexDump: this.logAllCommunicationHexDump, - socketChunkSize: this.socketChunkSize, - webInspectorMaxFrameLength: this.webInspectorMaxFrameLength, - udid: this.udid, - }); - } -} diff --git a/lib/remote-debugger-real-device.ts b/lib/remote-debugger-real-device.ts new file mode 100644 index 0000000..a53f3eb --- /dev/null +++ b/lib/remote-debugger-real-device.ts @@ -0,0 +1,28 @@ +import RemoteDebugger from './remote-debugger'; +import { RpcClientRealDevice } from './rpc'; +import type { RemoteDebuggerRealDeviceOptions } from './types'; + +export class RemoteDebuggerRealDevice extends RemoteDebugger { + private readonly _udid: string; + + constructor (opts: RemoteDebuggerRealDeviceOptions) { + super(opts); + this._udid = opts.udid; + this._skippedApps = ['lockdownd']; + } + + override initRpcClient (): void { + this._rpcClient = new RpcClientRealDevice({ + bundleId: this._bundleId, + platformVersion: this._platformVersion, + isSafari: this._isSafari, + logAllCommunication: this._logAllCommunication, + logAllCommunicationHexDump: this._logAllCommunicationHexDump, + socketChunkSize: this._socketChunkSize, + webInspectorMaxFrameLength: this._webInspectorMaxFrameLength, + udid: this._udid, + }); + } +} + +export default RemoteDebuggerRealDevice; diff --git a/lib/remote-debugger.js b/lib/remote-debugger.js deleted file mode 100644 index 4a8cf05..0000000 --- a/lib/remote-debugger.js +++ /dev/null @@ -1,299 +0,0 @@ -import { EventEmitter } from 'events'; -import defaultLog from './logger'; -import { RpcClientSimulator } from './rpc'; -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'; - - -export const REMOTE_DEBUGGER_PORT = 27753; -/* How many milliseconds to wait for webkit to return a response before timing out */ -export const RPC_RESPONSE_TIMEOUT_MS = 5000; -const PAGE_READY_TIMEOUT_MS = 5000; -const { version: MODULE_VERSION } = getModuleProperties(); - - -export class RemoteDebugger extends EventEmitter { - // properties - /** @type {string[]|undefined} */ - _skippedApps; - /** @type {import('@appium/types').StringRecord} */ - _clientEventListeners; - /** @type {import('./types').AppDict} */ - appDict; - /** @type {string|null|undefined} */ - appIdKey; - /** @type {string|number|null|undefined} */ - pageIdKey; - /** @type {Record[]|undefined} */ - connectedDrivers; - /** @type {Record[]|undefined} */ - currentState; - /** @type {boolean|undefined} */ - connected; - /** @type {import('bluebird')} */ - pageLoadDelay; - /** @type {import('bluebird')} */ - navigationDelay; - /** @type {import('./rpc/rpc-client').RpcClient?} */ - rpcClient; - /** @type {string|undefined} */ - pageLoadStrategy; - /** @type {import('@appium/types').AppiumLogger} */ - _log; - - // events - /** @type {string} */ - static EVENT_PAGE_CHANGE; - /** @type {string} */ - static EVENT_DISCONNECT; - /** @type {string} */ - static EVENT_FRAMES_DETACHED; - - // methods - 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 - 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 - */ - constructor (opts = {}) { - super(); - - // @ts-ignore This is OK - this._log = opts.log ?? defaultLog; - this.log.info(`Remote Debugger version ${MODULE_VERSION}`); - - const { - bundleId, - additionalBundleIds = [], - platformVersion, - isSafari = true, - includeSafari = false, - useNewSafari = false, - pageLoadMs, - host, - port = REMOTE_DEBUGGER_PORT, - socketPath, - pageReadyTimeout = PAGE_READY_TIMEOUT_MS, - remoteDebugProxy, - garbageCollectOnExecute = false, - logFullResponse = false, - logAllCommunication = false, - logAllCommunicationHexDump = false, - webInspectorMaxFrameLength, - socketChunkSize, - fullPageInitialization, - pageLoadStrategy, - } = opts; - - this.bundleId = bundleId; - this.additionalBundleIds = additionalBundleIds; - this.platformVersion = platformVersion; - this.isSafari = isSafari; - this.includeSafari = includeSafari; - this.useNewSafari = useNewSafari; - this.pageLoadMs = pageLoadMs; - this.log.debug(`useNewSafari --> ${this.useNewSafari}`); - - this.garbageCollectOnExecute = garbageCollectOnExecute; - - this.host = host; - this.port = port; - this.socketPath = socketPath; - this.remoteDebugProxy = remoteDebugProxy; - this.pageReadyTimeout = pageReadyTimeout; - - this.logAllCommunication = _.isNil(logAllCommunication) ? !!logFullResponse : !!logAllCommunication; - this.logAllCommunicationHexDump = logAllCommunicationHexDump; - this.socketChunkSize = socketChunkSize; - - if (_.isInteger(webInspectorMaxFrameLength)) { - this.webInspectorMaxFrameLength = webInspectorMaxFrameLength; - } - - this.fullPageInitialization = fullPageInitialization; - - this.pageLoadStrategy = pageLoadStrategy; - } - - /** - * @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 = {}; - this.appIdKey = null; - this.pageIdKey = null; - this.pageLoading = false; - this._navigatingToPage = false; - this.allowNavigationWithoutReload = false; - - this.rpcClient = null; - this._clientEventListeners = {}; - } - - /** - * @returns {void} - */ - teardown () { - this.log.debug('Cleaning up listeners'); - - this.appDict = {}; - this.appIdKey = null; - this.pageIdKey = null; - this.pageLoading = false; - - this.rpcClient = null; - - this.removeAllListeners(RemoteDebugger.EVENT_PAGE_CHANGE); - this.removeAllListeners(RemoteDebugger.EVENT_DISCONNECT); - } - - /** - * @returns {void} - */ - initRpcClient () { - this.rpcClient = new RpcClientSimulator({ - bundleId: this.bundleId, - platformVersion: this.platformVersion, - isSafari: this.isSafari, - host: this.host, - port: this.port, - socketPath: this.socketPath, - messageProxy: this.remoteDebugProxy, - logAllCommunication: this.logAllCommunication, - logAllCommunicationHexDump: this.logAllCommunicationHexDump, - fullPageInitialization: this.fullPageInitialization, - webInspectorMaxFrameLength: this.webInspectorMaxFrameLength, - }); - } - - /** - * @returns {boolean} - */ - get isConnected () { - return !!this.rpcClient?.isConnected; - } - - /** - * @param {boolean} allow - */ - set allowNavigationWithoutReload (allow) { - this._allowNavigationWithoutReload = allow; - } - - /** - * @returns {boolean} - */ - get allowNavigationWithoutReload () { - return !!this._allowNavigationWithoutReload; - } - - /** - * @returns {string[]} - */ - get skippedApps () { - return this._skippedApps ?? []; - } -} - -for (const [name, event] of _.toPairs(eventMixins.events)) { - RemoteDebugger[name] = event; -} - -export default RemoteDebugger; - -/** - * @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/remote-debugger.ts b/lib/remote-debugger.ts new file mode 100644 index 0000000..ab4b85b --- /dev/null +++ b/lib/remote-debugger.ts @@ -0,0 +1,251 @@ +import { EventEmitter } from 'events'; +import defaultLog from './logger'; +import { RpcClientSimulator } from './rpc'; +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 type { + RemoteDebuggerOptions, + AppDict, + EventListener +} from './types'; +import type { AppiumLogger, StringRecord } from '@appium/types'; +import type { RpcClient } from './rpc/rpc-client'; +import type B from 'bluebird'; + + +export const REMOTE_DEBUGGER_PORT = 27753; +const PAGE_READY_TIMEOUT_MS = 5000; +const { version: MODULE_VERSION } = getModuleProperties(); + + +export class RemoteDebugger extends EventEmitter { + protected _skippedApps: string[]; + protected _clientEventListeners: StringRecord; + protected _appDict: AppDict; + protected _appIdKey: string | null | undefined; + protected _pageIdKey: string | number | null | undefined; + protected _connectedDrivers: StringRecord[] | undefined; + protected _currentState: string | undefined; + protected _pageLoadDelay: B; + protected _rpcClient: RpcClient | null; + protected _pageLoading: boolean; + protected _navigatingToPage: boolean; + protected _allowNavigationWithoutReload: boolean; + protected readonly _pageLoadStrategy: string | undefined; + protected readonly _log: AppiumLogger; + protected readonly _bundleId: string | undefined; + protected readonly _additionalBundleIds: string[] | undefined; + protected readonly _platformVersion: string | undefined; + protected readonly _isSafari: boolean; + protected readonly _includeSafari: boolean; + protected readonly _useNewSafari: boolean; + protected readonly _pageLoadMs: number | undefined; + protected readonly _garbageCollectOnExecute: boolean; + protected readonly _host: string | undefined; + protected readonly _port: number | undefined; + protected readonly _socketPath: string | undefined; + protected readonly _remoteDebugProxy: any | undefined; + protected readonly _pageReadyTimeout: number; + protected readonly _logAllCommunication: boolean; + protected readonly _logAllCommunicationHexDump: boolean; + protected readonly _socketChunkSize: number | undefined; + protected readonly _webInspectorMaxFrameLength: number | undefined; + protected readonly _fullPageInitialization: boolean | undefined; + + // events + static readonly EVENT_PAGE_CHANGE: string; + static readonly EVENT_DISCONNECT: string; + static readonly EVENT_FRAMES_DETACHED: string; + + // methods + setConnectionKey = connectMixins.setConnectionKey; + disconnect = connectMixins.disconnect; + 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; + startConsole = eventMixins.startConsole; + stopConsole = eventMixins.stopConsole; + startNetwork = eventMixins.startNetwork; + stopNetwork = eventMixins.stopNetwork; + launchSafari = miscellaneousMixins.launchSafari; + startTimeline = miscellaneousMixins.startTimeline; + stopTimeline = miscellaneousMixins.stopTimeline; + overrideUserAgent = miscellaneousMixins.overrideUserAgent; + garbageCollect = miscellaneousMixins.garbageCollect; + + // Callbacks + onPageChange = messageHandlerMixins.onPageChange; + onConnectedApplicationList = messageHandlerMixins.onConnectedApplicationList; + onAppConnect = messageHandlerMixins.onAppConnect; + onAppDisconnect = messageHandlerMixins.onAppDisconnect; + onAppUpdate = messageHandlerMixins.onAppUpdate; + onConnectedDriverList = messageHandlerMixins.onConnectedDriverList; + onCurrentState = messageHandlerMixins.onCurrentState; + frameDetached = navigationMixins.frameDetached; + + constructor (opts: RemoteDebuggerOptions = {}) { + super(); + + this._log = opts.log ?? defaultLog; + this.log.info(`Remote Debugger version ${MODULE_VERSION}`); + + const { + bundleId, + additionalBundleIds = [], + platformVersion, + isSafari = true, + includeSafari = false, + useNewSafari = false, + pageLoadMs, + host, + port = REMOTE_DEBUGGER_PORT, + socketPath, + pageReadyTimeout = PAGE_READY_TIMEOUT_MS, + remoteDebugProxy, + garbageCollectOnExecute = false, + logFullResponse = false, + logAllCommunication = false, + logAllCommunicationHexDump = false, + webInspectorMaxFrameLength, + socketChunkSize, + fullPageInitialization, + pageLoadStrategy, + } = opts; + + this._bundleId = bundleId; + this._additionalBundleIds = additionalBundleIds; + this._platformVersion = platformVersion; + this._isSafari = isSafari; + this._includeSafari = includeSafari; + this._useNewSafari = useNewSafari; + this._pageLoadMs = pageLoadMs; + this.log.debug(`useNewSafari --> ${this._useNewSafari}`); + + this._garbageCollectOnExecute = garbageCollectOnExecute; + + this._host = host; + this._port = port; + this._socketPath = socketPath; + this._remoteDebugProxy = remoteDebugProxy; + this._pageReadyTimeout = pageReadyTimeout; + + this._logAllCommunication = _.isNil(logAllCommunication) ? !!logFullResponse : !!logAllCommunication; + this._logAllCommunicationHexDump = logAllCommunicationHexDump; + this._socketChunkSize = socketChunkSize; + + if (_.isInteger(webInspectorMaxFrameLength)) { + this._webInspectorMaxFrameLength = webInspectorMaxFrameLength; + } + + this._fullPageInitialization = fullPageInitialization; + + this._pageLoadStrategy = pageLoadStrategy; + this._skippedApps = []; + } + + get log(): AppiumLogger { + return this._log; + } + + requireRpcClient(checkConnected: boolean = false): RpcClient { + 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; + } + + setup (): void { + // app handling configuration + this._appDict = {}; + this._appIdKey = null; + this._pageIdKey = null; + this._pageLoading = false; + this._navigatingToPage = false; + this._allowNavigationWithoutReload = false; + + this._rpcClient = null; + this._clientEventListeners = {}; + } + + teardown (): void { + this.log.debug('Cleaning up listeners'); + + this._appDict = {}; + this._appIdKey = null; + this._pageIdKey = null; + this._pageLoading = false; + + this._rpcClient = null; + + for (const evt of [ + RemoteDebugger.EVENT_DISCONNECT, + RemoteDebugger.EVENT_PAGE_CHANGE, + RemoteDebugger.EVENT_FRAMES_DETACHED, + ]) { + this.removeAllListeners(evt); + } + } + + initRpcClient (): void { + this._rpcClient = new RpcClientSimulator({ + bundleId: this._bundleId, + platformVersion: this._platformVersion, + isSafari: this._isSafari, + host: this._host, + port: this._port, + socketPath: this._socketPath, + messageProxy: this._remoteDebugProxy, + logAllCommunication: this._logAllCommunication, + logAllCommunicationHexDump: this._logAllCommunicationHexDump, + fullPageInitialization: this._fullPageInitialization, + webInspectorMaxFrameLength: this._webInspectorMaxFrameLength, + }); + } + + get isConnected (): boolean { + return !!this._rpcClient?.isConnected; + } + + set allowNavigationWithoutReload (allow: boolean) { + this._allowNavigationWithoutReload = allow; + } + + get allowNavigationWithoutReload (): boolean { + return !!this._allowNavigationWithoutReload; + } + + get currentState (): string | undefined { + return this._currentState; + } +} + +for (const [name, event] of _.toPairs(eventMixins.events)) { + RemoteDebugger[name] = event; +} + +export default RemoteDebugger; diff --git a/lib/types.ts b/lib/types.ts index 50fdf34..9ec255c 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,4 +1,4 @@ -import type { StringRecord } from '@appium/types'; +import type { StringRecord, AppiumLogger } from '@appium/types'; import type B from 'bluebird'; export interface DeferredPromise { @@ -37,3 +37,46 @@ export interface Page { } export type AppDict = StringRecord; + +export type EventListener = (event: StringRecord) => any; + +export interface RemoteDebuggerOptions { + /** id of the app being connected to */ + bundleId?: string; + /** array of possible bundle ids that the inspector could return */ + additionalBundleIds?: string[]; + /** version of iOS */ + platformVersion?: string; + isSafari?: boolean; + includeSafari?: boolean; + /** for web inspector, whether this is a new Safari instance */ + useNewSafari?: boolean; + /** the time, in ms, that should be waited for page loading */ + pageLoadMs?: number; + /** the remote debugger's host address */ + host?: string; + /** the remote debugger port through which to communicate */ + port?: number; + socketPath?: string; + pageReadyTimeout?: number; + remoteDebugProxy?: string; + garbageCollectOnExecute?: boolean; + logFullResponse?: boolean; + /** log plists sent and received from Web Inspector */ + logAllCommunication?: boolean; + /** log communication from Web Inspector as hex dump */ + logAllCommunicationHexDump?: boolean; + /** The maximum size in bytes of a single data frame in the device communication protocol */ + webInspectorMaxFrameLength?: number; + /** size, in bytes, of chunks of data sent to Web Inspector (real device only) */ + socketChunkSize?: number; + fullPageInitialization?: boolean; + pageLoadStrategy?: string; + log?: AppiumLogger; +} + +interface RemoteDebuggerRealDeviceSpecificOptions { + udid: string; +} + +export type RemoteDebuggerRealDeviceOptions = RemoteDebuggerRealDeviceSpecificOptions & RemoteDebuggerOptions; diff --git a/test/functional/safari-e2e-specs.js b/test/functional/safari-e2e-specs.js index 6984338..a803adb 100644 --- a/test/functional/safari-e2e-specs.js +++ b/test/functional/safari-e2e-specs.js @@ -87,8 +87,7 @@ describe('Safari remote debugger', function () { const maxRetries = process.env.CI ? 10 : 2; await retry(maxRetries, async () => await sim.openUrl(address)); await retry(maxRetries, async () => { - await rd.connect(60000); - if (_.isEmpty(rd.appDict)) { + if (_.isEmpty(await rd.connect(60000))) { await rd.disconnect(); throw new Error('The remote debugger did not return any connected applications'); } diff --git a/test/unit/mixins/connect-specs.js b/test/unit/mixins/connect-specs.js index 70b6191..6a0a15d 100644 --- a/test/unit/mixins/connect-specs.js +++ b/test/unit/mixins/connect-specs.js @@ -23,7 +23,7 @@ describe('connect', function () { describe('getPossibleDebuggerAppKeys', function () { it('should return the app key of the specified bundleIds', function () { - rd.appDict = { + rd._appDict = { ['42']: { bundleId: 'io.appium.bundle1' }, @@ -42,7 +42,7 @@ describe('connect', function () { ]; for (const webviewBundleId of webviewBundleIds) { it(`should return the app key of ${webviewBundleId}`, function () { - rd.appDict = { + rd._appDict = { ['42']: { bundleId: webviewBundleId } @@ -51,7 +51,7 @@ describe('connect', function () { }); } it('should return the app key for the bundleIds when proxied', function () { - rd.appDict = { + rd._appDict = { ['42']: { bundleId: 'io.appium.bundle', isProxy: false @@ -65,11 +65,11 @@ describe('connect', function () { expect(getPossibleDebuggerAppKeys.bind(rd)(['io.appium.bundle'])).to.eql(['42', '43']); }); it('should return an empty array when there is no appropriate app', function () { - rd.appDict = {}; + rd._appDict = {}; expect(getPossibleDebuggerAppKeys.bind(rd)('io.appium.bundle')).to.eql([]); }); it('should return the all app keys when the bundlIds array includes a wildcard', function () { - rd.appDict = { + rd._appDict = { ['42']: { bundleId: 'io.appium.bundle1' }, diff --git a/test/unit/mixins/execute-specs.js b/test/unit/mixins/execute-specs.js index ae46058..b435de6 100644 --- a/test/unit/mixins/execute-specs.js +++ b/test/unit/mixins/execute-specs.js @@ -15,16 +15,16 @@ describe('execute', function () { describe('executeAtom', function () { it('should execute atom and call send event on rpc client', async function () { const ctx = { - appIdKey: 'appId', - pageIdKey: 'pageId', + _appIdKey: 'appId', + _pageIdKey: 'pageId', log: {debug: () => {}}, execute, - rpcClient: { + _rpcClient: { isConnected: true, send: () => ({hello: 'world'}), }, }; - ctx.requireRpcClient = () => ctx.rpcClient; + ctx.requireRpcClient = () => ctx._rpcClient; const res = await executeAtom.call(ctx, 'find_element', ['css selector', '#id', {ELEMENT: 'foo'}]); res.should.eql({hello: 'world'}); }); @@ -32,17 +32,17 @@ describe('execute', function () { describe('.executeAtomAsync', function () { it('calls rpcClient.send', async function () { const ctx = { - appIdKey: 'appId', - pageIdKey: 'pageId', + _appIdKey: 'appId', + _pageIdKey: 'pageId', log: {debug: () => {}}, execute, - rpcClient: { + _rpcClient: { isConnected: true, send: () => ({result: {objectId: 'fake-object-id'}}), }, }; - ctx.requireRpcClient = () => ctx.rpcClient; - const sendSpy = sinon.spy(ctx.rpcClient, 'send'); + 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; callArgs[0].should.equal('Runtime.evaluate'); @@ -52,22 +52,22 @@ describe('execute', function () { describe('.callFunction', function () { it('call rpcClient.send', async function () { const ctx = { - appIdKey: 'fakeAppId', - pageIdKey: 'fakePageId', + _appIdKey: 'fakeAppId', + _pageIdKey: 'fakePageId', log: {debug: () => {}}, - garbageCollectOnExecute: true, + _garbageCollectOnExecute: true, garbageCollect () { }, - rpcClient: { + _rpcClient: { send () { return {result: {objectId: 'fake-object-id'}}; }, isConnected: true, }, waitForDom () { }, - pageLoading: true, + _pageLoading: true, }; - ctx.requireRpcClient = () => ctx.rpcClient; - const sendSpy = sinon.spy(ctx.rpcClient, 'send'); + 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'); sendSpy.firstCall.args[1].should.eql({ diff --git a/test/unit/mixins/message-handlers-specs.js b/test/unit/mixins/message-handlers-specs.js index e7d781e..8308917 100644 --- a/test/unit/mixins/message-handlers-specs.js +++ b/test/unit/mixins/message-handlers-specs.js @@ -23,7 +23,7 @@ describe('connect', function () { describe('getDebuggerAppKey', function () { it('should return the app key for the bundle', function () { - rd.appDict = { + rd._appDict = { ['42']: { bundleId: 'io.appium.bundle' } @@ -31,7 +31,7 @@ describe('connect', function () { getDebuggerAppKey.bind(rd)('io.appium.bundle').should.equal('42'); }); it('should return the app key for the bundle when proxied', function () { - rd.appDict = { + rd._appDict = { ['42']: { bundleId: 'io.appium.bundle', isProxy: false @@ -45,7 +45,7 @@ describe('connect', function () { getDebuggerAppKey.bind(rd)('io.appium.bundle').should.equal('43'); }); it('should return undefined when there is no appropriate app', function () { - rd.appDict = {}; + rd._appDict = {}; expect(getDebuggerAppKey.bind(rd)('io.appium.bundle')).to.not.exist; }); });