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; }); });