UNPKG

appium-remote-debugger

Version:
379 lines 19 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.setConnectionKey = setConnectionKey; exports.connect = connect; exports.disconnect = disconnect; exports.selectApp = selectApp; exports.selectPage = selectPage; exports.getPossibleDebuggerAppKeys = getPossibleDebuggerAppKeys; const utils_1 = require("../utils"); const events_1 = require("./events"); const support_1 = require("@appium/support"); const asyncbox_1 = require("asyncbox"); const lodash_1 = __importDefault(require("lodash")); const property_accessors_1 = require("./property-accessors"); const rpc_client_1 = require("../rpc/rpc-client"); const APP_CONNECT_TIMEOUT_MS = 0; const APP_CONNECT_INTERVAL_MS = 100; const SELECT_APP_RETRIES = 20; const SELECT_APP_RETRY_SLEEP_MS = 500; const SAFARI_BUNDLE_ID = 'com.apple.mobilesafari'; const BLANK_PAGE_URL = 'about:blank'; const WEB_CONTENT_PROCESS_BUNDLE_ID = 'process-com.apple.WebKit.WebContent'; const SAFARI_VIEW_PROCESS_BUNDLE_ID = 'process-SafariViewService'; const SAFARI_VIEW_BUNDLE_ID = 'com.apple.SafariViewService'; const WILDCARD_BUNDLE_ID = '*'; /** * Sends a connection key request to the Web Inspector. * This method only waits to ensure the socket connection works, as the response * from Web Inspector can take a long time. */ async function setConnectionKey() { this.log.debug('Sending connection key request'); // send but only wait to make sure the socket worked // as response from Web Inspector can take a long time await this.requireRpcClient().send('setConnectionKey', {}, false); } /** * Establishes a connection to the remote debugger and initializes the RPC client. * Sets up event listeners for debugger-level events and waits for applications * to be reported if a timeout is specified. * * @param timeout - Maximum time in milliseconds to wait for applications to be reported. * Defaults to 0 (no waiting). If provided, the method will wait up to * this duration for applications to appear in the app dictionary. * @returns A promise that resolves to the application dictionary containing all * connected applications. */ async function connect(timeout = APP_CONNECT_TIMEOUT_MS) { this.setup(); // initialize the rpc client await this.initRpcClient(); const rpcClient = this.requireRpcClient(); // listen for basic debugger-level events rpcClient.on('_rpc_reportSetup:', lodash_1.default.noop); rpcClient.on('_rpc_forwardGetListing:', this.onPageChange.bind(this)); rpcClient.on('_rpc_reportConnectedApplicationList:', this.onConnectedApplicationList.bind(this)); rpcClient.on('_rpc_applicationConnected:', this.onAppConnect.bind(this)); rpcClient.on('_rpc_applicationDisconnected:', this.onAppDisconnect.bind(this)); rpcClient.on('_rpc_applicationUpdated:', this.onAppUpdate.bind(this)); rpcClient.on('_rpc_reportConnectedDriverList:', this.onConnectedDriverList.bind(this)); rpcClient.on('_rpc_reportCurrentState:', this.onCurrentState.bind(this)); rpcClient.on('Page.frameDetached', this.frameDetached.bind(this)); await rpcClient.connect(); // get the connection information about the app try { await this.setConnectionKey(); if (timeout) { const timer = new support_1.timing.Timer().start(); this.log.debug(`Waiting up to ${timeout}ms for applications to be reported`); try { await (0, asyncbox_1.waitForCondition)(() => !lodash_1.default.isEmpty((0, property_accessors_1.getAppDict)(this)), { waitMs: timeout, intervalMs: APP_CONNECT_INTERVAL_MS, }); this.log.debug(`Retrieved ${support_1.util.pluralize('application', lodash_1.default.size((0, property_accessors_1.getAppDict)(this)), true)} ` + `within ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`); } catch { this.log.debug(`Timed out waiting for applications to be reported`); } } return this.appDict; } catch (err) { this.log.error(`Error setting connection key: ${err.message}`); await this.disconnect(); throw err; } } /** * Disconnects from the remote debugger by closing the RPC client connection, * emitting a disconnect event, and performing cleanup via teardown. */ async function disconnect() { await (0, property_accessors_1.getRcpClient)(this)?.disconnect(); this.emit(events_1.events.EVENT_DISCONNECT, true); this.teardown(); } /** * Selects an application from the available connected applications. * Searches for an app matching the provided URL and bundle IDs, then returns * all pages from the selected application. * * @param currentUrl - Optional URL to match when selecting an application. * If provided, the method will try to find an app containing * a page with this URL. * @param maxTries - Maximum number of retry attempts when searching for an app. * Defaults to SELECT_APP_RETRIES (20). * @param ignoreAboutBlankUrl - If true, pages with 'about:blank' URL will be * excluded from the results. Defaults to false. * @returns A promise that resolves to an array of Page objects from the selected * application. Returns an empty array if no applications are connected. */ async function selectApp(currentUrl = null, maxTries = SELECT_APP_RETRIES, ignoreAboutBlankUrl = false) { this.log.debug('Selecting application'); const timer = new support_1.timing.Timer().start(); if (lodash_1.default.isEmpty((0, property_accessors_1.getAppDict)(this))) { this.log.debug('No applications currently connected.'); return []; } if (isAppIgnored(this)) { return []; } const { appIdKey } = await searchForApp.bind(this)(currentUrl, maxTries, ignoreAboutBlankUrl); if ((0, property_accessors_1.getAppIdKey)(this) !== appIdKey) { this.log.debug(`Received altered app id, updating from '${(0, property_accessors_1.getAppIdKey)(this)}' to '${appIdKey}'`); (0, property_accessors_1.setAppIdKey)(this, appIdKey); } logApplicationDictionary.bind(this)(); // translate the dictionary into a useful form, and return to sender this.log.debug(`Finally selecting app ${(0, property_accessors_1.getAppIdKey)(this)}`); const fullPageArray = []; for (const [app, info] of lodash_1.default.toPairs((0, property_accessors_1.getAppDict)(this))) { if (!lodash_1.default.isArray(info.pageArray) || !info.isActive) { continue; } const id = app.replace('PID:', ''); for (const page of info.pageArray) { if (!(ignoreAboutBlankUrl && page.url === BLANK_PAGE_URL)) { const pageDict = lodash_1.default.clone(page); pageDict.id = `${id}.${pageDict.id}`; pageDict.bundleId = info.bundleId; fullPageArray.push(pageDict); } } } this.log.debug(`Selected app after ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`); return fullPageArray; } /** * Selects a specific page within an application and forwards socket setup. * Optionally waits for the page to be ready based on the page load strategy. * * @param appIdKey - The application identifier key. Will be prefixed with 'PID:' * if not already present. * @param pageIdKey - The page identifier key to select. * @param skipReadyCheck - If true, skips the page readiness check. Defaults to false. * When false, the method will wait for the page to be ready * according to the configured page load strategy. */ async function selectPage(appIdKey, pageIdKey, skipReadyCheck = false) { const fullAppIdKey = lodash_1.default.startsWith(`${appIdKey}`, 'PID:') ? `${appIdKey}` : `PID:${appIdKey}`; (0, property_accessors_1.setAppIdKey)(this, fullAppIdKey); (0, property_accessors_1.setPageIdKey)(this, pageIdKey); this.log.debug(`Selecting page '${pageIdKey}' on app '${fullAppIdKey}' and forwarding socket setup`); const timer = new support_1.timing.Timer().start(); const pageReadinessDetector = skipReadyCheck ? undefined : { timeoutMs: this.pageLoadMs, readinessDetector: (readyState) => this.isPageLoadingCompleted(readyState), }; await this.requireRpcClient().selectPage(fullAppIdKey, pageIdKey, pageReadinessDetector); this.log.debug(`Selected page after ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`); } /** * Finds app keys based on assigned bundle IDs from the app dictionary. * When bundleIds includes a wildcard ('*'), returns all app keys in the app dictionary. * Also handles proxy applications that may act on behalf of other bundle IDs. * * @param bundleIds - Array of bundle identifiers to match against. If the array * contains a wildcard ('*'), all apps will be returned. * @returns Array of application identifier keys that match the provided bundle IDs. */ function getPossibleDebuggerAppKeys(bundleIds) { const appDict = (0, property_accessors_1.getAppDict)(this); if (bundleIds.includes(WILDCARD_BUNDLE_ID)) { this.log.info('Returning all apps because the list of matching bundle identifiers includes a wildcard'); return lodash_1.default.keys(appDict); } // go through the possible bundle identifiers const possibleBundleIds = lodash_1.default.uniq([ utils_1.WEB_CONTENT_BUNDLE_ID, WEB_CONTENT_PROCESS_BUNDLE_ID, SAFARI_VIEW_PROCESS_BUNDLE_ID, SAFARI_VIEW_BUNDLE_ID, ...bundleIds, ]); this.log.debug(`Checking for apps with matching bundle identifiers: ${possibleBundleIds.join(', ')}`); const proxiedAppIds = []; for (const bundleId of possibleBundleIds) { // now we need to determine if we should pick a proxy for this instead for (const appId of (0, utils_1.appIdsForBundle)(bundleId, appDict)) { if (proxiedAppIds.includes(appId)) { continue; } proxiedAppIds.push(appId); this.log.debug(`Found app id key '${appId}' for bundle '${bundleId}'`); for (const [key, data] of lodash_1.default.toPairs(appDict)) { if (data.isProxy && data.hostId === appId && !proxiedAppIds.includes(key)) { this.log.debug(`Found separate bundleId '${data.bundleId}' ` + `acting as proxy for '${bundleId}', with app id '${key}'`); proxiedAppIds.push(key); } } } } this.log.debug(`You may also consider providing more values to 'additionalWebviewBundleIds' ` + `capability to match other applications. Add a wildcard ('*') to match all apps.`); return lodash_1.default.uniq(proxiedAppIds); } /** * Searches for an application matching the given criteria by retrying with * exponential backoff. Attempts to connect to apps matching the bundle IDs * and optionally filters by URL. * * @param currentUrl - Optional URL to match when searching for a page. * If provided, only apps containing a page with this URL * will be considered. * @param maxTries - Maximum number of retry attempts. * @param ignoreAboutBlankUrl - If true, pages with 'about:blank' URL will be * ignored during the search. * @returns A promise that resolves to an AppPage object containing the matched * app ID key and page dictionary. * @throws Error if no valid webapp can be connected after all retry attempts. */ async function searchForApp(currentUrl, maxTries, ignoreAboutBlankUrl) { const bundleIds = lodash_1.default.compact([ (0, property_accessors_1.getBundleId)(this), ...((0, property_accessors_1.getAdditionalBundleIds)(this) ?? []), ...((0, property_accessors_1.getIncludeSafari)(this) && !(0, property_accessors_1.getIsSafari)(this) ? [SAFARI_BUNDLE_ID] : []), ]); let retryCount = 0; return (await (0, asyncbox_1.retryInterval)(maxTries, SELECT_APP_RETRY_SLEEP_MS, async () => { logApplicationDictionary.bind(this)(); const possibleAppIds = getPossibleDebuggerAppKeys.bind(this)(bundleIds); this.log.debug(`Trying out the possible app ids: ${possibleAppIds.join(', ')} (try #${retryCount + 1} of ${maxTries})`); for (const attemptedAppIdKey of possibleAppIds) { const appInfo = (0, property_accessors_1.getAppDict)(this)[attemptedAppIdKey]; if (!appInfo) { continue; } if (!appInfo.isActive || (!appInfo.isAutomationEnabled && appInfo.bundleId === SAFARI_BUNDLE_ID)) { this.log.debug(`Skipping app '${attemptedAppIdKey}' because it is not ${appInfo.isActive ? 'enabled' : 'active'}`); continue; } this.log.debug(`Attempting app '${attemptedAppIdKey}'`); try { const [appIdKey, pageDict] = await this.requireRpcClient().selectApp(attemptedAppIdKey); // save the page array for this app (0, property_accessors_1.getAppDict)(this)[appIdKey].pageArray = (0, utils_1.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 = searchForPage.bind(this)((0, property_accessors_1.getAppDict)(this), currentUrl, ignoreAboutBlankUrl); if (result) { return result; } if (currentUrl) { this.log.debug(`Received app, but expected url ('${currentUrl}') was not found. Trying again.`); } else { this.log.debug('Received app, but no match was found. Trying again.'); } } catch (err) { if (![rpc_client_1.NEW_APP_CONNECTED_ERROR, rpc_client_1.EMPTY_PAGE_DICTIONARY_ERROR].some((msg) => msg === err.message)) { this.log.debug(err.stack); } this.log.warn(`The application ${attemptedAppIdKey} is not connectable yet: ${err.message}`); } } retryCount++; throw new Error(`Could not connect to a valid webapp. Make sure it is debuggable and has at least one active page.`); })); } /** * Searches through the application dictionary to find a page matching the given URL. * Only considers active applications with non-empty page arrays. * * @param appsDict - The application dictionary to search through. * @param currentUrl - Optional URL to match. If provided, only pages with this exact * URL or with this URL followed by '/' will be considered. * @param ignoreAboutBlankUrl - If true, pages with 'about:blank' URL will be ignored. * @returns An AppPage object if a matching page is found, null otherwise. */ function searchForPage(appsDict, currentUrl = null, ignoreAboutBlankUrl = false) { for (const appDict of lodash_1.default.values(appsDict)) { if (!appDict || !appDict.isActive || !appDict.pageArray || lodash_1.default.isEmpty(appDict.pageArray)) { continue; } for (const page of appDict.pageArray) { if ((!ignoreAboutBlankUrl || page.url !== BLANK_PAGE_URL) && (!currentUrl || page.url === currentUrl || page.url === `${currentUrl}/`)) { return { appIdKey: appDict.id, pageDict: page, }; } } } return null; } /** * Checks whether all apps in the app dictionary have bundle IDs that are in the * configured ignore list and logs the result accordingly. * * Uses Set-based lookups for O(1) performance and computes the actual intersection * of appDict bundle IDs with the ignore list for accurate log messages. * * @param instance - The RemoteDebugger instance. * @returns `true` if webview search should be skipped (all apps are ignored), * `false` if the search should proceed. */ function isAppIgnored(instance) { const ignoredBundleIds = (0, property_accessors_1.getIgnoredBundleIds)(instance) ?? []; if (ignoredBundleIds.length === 0) { return false; } const ignoredSet = new Set(ignoredBundleIds); const appBundleIds = new Set(lodash_1.default.values((0, property_accessors_1.getAppDict)(instance)).map((app) => app.bundleId)); if (appBundleIds.size === 0) { return false; } const nonIgnoredBundleIds = [...appBundleIds].filter((id) => !ignoredSet.has(id)); const actuallyIgnoredIds = [...appBundleIds].filter((id) => ignoredSet.has(id)); if (nonIgnoredBundleIds.length === 0) { instance.log.info(`All apps reported by Web Inspector have bundle IDs in the ignore list ` + `(${actuallyIgnoredIds.join(', ')}). Skipping webview search.`); return true; } if (actuallyIgnoredIds.length > 0) { instance.log.debug(`Ignoring apps with bundle IDs: ${actuallyIgnoredIds.join(', ')}. ` + `${support_1.util.pluralize('app', nonIgnoredBundleIds.length, true)} remain for webview search.`); } return false; } /** * Logs the current application dictionary to the debug log. * Displays all applications, their properties, and their associated pages * in a formatted structure. */ function logApplicationDictionary() { this.log.debug('Current applications available:'); for (const [app, info] of lodash_1.default.toPairs((0, property_accessors_1.getAppDict)(this))) { this.log.debug(` Application: "${app}"`); for (const [key, value] of lodash_1.default.toPairs(info)) { if (key === 'pageArray' && Array.isArray(value) && value.length) { this.log.debug(` ${key}:`); for (const page of value) { let prefix = '- '; for (const [k, v] of lodash_1.default.toPairs(page)) { this.log.debug(` ${prefix}${k}: ${JSON.stringify(v)}`); prefix = ' '; } } } else { const valueString = lodash_1.default.isFunction(value) ? '[Function]' : JSON.stringify(value); this.log.debug(` ${key}: ${valueString}`); } } } } //# sourceMappingURL=connect.js.map