UNPKG

appium-remote-debugger

Version:
1,272 lines (1,182 loc) 45.3 kB
import {RemoteMessages} from './remote-messages'; import {waitForCondition} from 'asyncbox'; import {log} from '../logger'; import _ from 'lodash'; import B from 'bluebird'; import RpcMessageHandler from './rpc-message-handler'; import {util, timing} from '@appium/support'; import {EventEmitter} from 'node:events'; import AsyncLock from 'async-lock'; import {convertJavascriptEvaluationResult} from '../utils'; import type {StringRecord} from '@appium/types'; import type { AppIdKey, PageIdKey, TargetId, TargetInfo, ProvisionalTargetInfo, RemoteCommandOpts, RemoteCommand, RawRemoteCommand, RpcClientOptions, RemoteCommandId, } from '../types'; const DATA_LOG_LENGTH = {length: 200}; const MIN_WAIT_FOR_TARGET_TIMEOUT_MS = 30000; const DEFAULT_TARGET_CREATION_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes const WAIT_FOR_TARGET_INTERVAL_MS = 100; const NO_TARGET_SUPPORTED_ERROR = `'target' domain was not found`; const MISSING_TARGET_ERROR_PATTERN = /Missing target/i; const NO_TARGET_PRESENT_YET_ERRORS = [ `domain was not found`, `some arguments of method`, `missing target`, ]; export const NEW_APP_CONNECTED_ERROR = 'New application has connected'; export const EMPTY_PAGE_DICTIONARY_ERROR = 'Empty page dictionary received'; const ON_PAGE_INITIALIZED_EVENT = 'onPageInitialized'; /** * Details about a pending page target notification. */ interface PendingPageTargetDetails { appIdKey: AppIdKey; pageIdKey: PageIdKey; pageReadinessDetector?: PageReadinessDetector; } /** * Pages to targets mapping with optional provisional target info and lock. */ interface PagesToTargets { [key: string]: TargetId | ProvisionalTargetInfo | AsyncLock | undefined; provisional?: ProvisionalTargetInfo; lock: AsyncLock; } /** * Mapping of application IDs to their pages and targets. */ type AppToTargetsMap = Record<AppIdKey, PagesToTargets>; /** * Detector for determining when a page is ready. */ interface PageReadinessDetector { timeoutMs: number; readinessDetector: (readyState: string) => boolean; } /** * Base class for RPC clients that communicate with the Web Inspector. * Provides functionality for managing targets, sending commands, and handling * page initialization. Subclasses must implement device-specific connection logic. */ export class RpcClient { protected readonly messageHandler: RpcMessageHandler; protected readonly remoteMessages: RemoteMessages; protected connected: boolean; protected readonly isSafari: boolean; protected readonly connId: string; protected readonly senderId: string; protected msgId: number; protected readonly udid!: string; protected readonly logAllCommunication?: boolean; protected readonly logAllCommunicationHexDump?: boolean; protected readonly socketChunkSize?: number; protected readonly webInspectorMaxFrameLength?: number; protected readonly fullPageInitialization?: boolean; protected readonly bundleId?: string; protected readonly pageLoadTimeoutMs?: number; protected readonly platformVersion: string; protected readonly _contexts: number[]; protected readonly _targets: AppToTargetsMap; protected readonly _targetSubscriptions: EventEmitter; protected _pendingTargetNotification?: PendingPageTargetDetails; protected readonly _targetCreationTimeoutMs: number; protected readonly _provisionedPages: Set<PageIdKey>; protected readonly _pageSelectionLock: AsyncLock; protected readonly _pageSelectionMonitor: EventEmitter; /** * @param opts - Options for configuring the RPC client. */ constructor(opts: RpcClientOptions = {}) { const { bundleId, platformVersion = '', isSafari = true, logAllCommunication = false, logAllCommunicationHexDump = false, webInspectorMaxFrameLength, socketChunkSize, fullPageInitialization = false, udid, pageLoadTimeoutMs, targetCreationTimeoutMs = DEFAULT_TARGET_CREATION_TIMEOUT_MS, } = opts; this.isSafari = isSafari; this.connected = false; this.connId = util.uuidV4(); this.senderId = util.uuidV4(); this.msgId = 0; this.udid = udid as string; this.logAllCommunication = logAllCommunication; this.logAllCommunicationHexDump = logAllCommunicationHexDump; this.socketChunkSize = socketChunkSize; this.webInspectorMaxFrameLength = webInspectorMaxFrameLength; this.pageLoadTimeoutMs = pageLoadTimeoutMs; this.fullPageInitialization = fullPageInitialization; this.bundleId = bundleId; this.platformVersion = platformVersion; this._contexts = []; this._targets = {}; this._targetSubscriptions = new EventEmitter(); this._provisionedPages = new Set(); this._pageSelectionLock = new AsyncLock(); this._pageSelectionMonitor = new EventEmitter(); this._targetCreationTimeoutMs = targetCreationTimeoutMs; this.remoteMessages = new RemoteMessages(); this.messageHandler = new RpcMessageHandler(); // add handlers for internal events this.messageHandler.on('Target.targetCreated', this.addTarget.bind(this)); this.messageHandler.on('Target.didCommitProvisionalTarget', this.updateTarget.bind(this)); this.messageHandler.on('Target.targetDestroyed', this.removeTarget.bind(this)); this.messageHandler.on( 'Runtime.executionContextCreated', this.onExecutionContextCreated.bind(this), ); this.messageHandler.on('Heap.garbageCollected', this.onGarbageCollected.bind(this)); } /** * Gets the list of execution context IDs. * * @returns Array of execution context IDs. */ get contexts(): number[] { return this._contexts; } /** * Gets the mapping of applications to their pages and targets. * * @returns The targets mapping structure. */ get targets(): AppToTargetsMap { return this._targets; } /** * Gets whether the client is currently connected. * * @returns True if connected, false otherwise. */ get isConnected(): boolean { return this.connected; } /** * Sets the connection status. * * @param connected - The connection status to set. */ set isConnected(connected: boolean) { this.connected = !!connected; } /** * Gets the event emitter for target subscriptions. * * @returns The target subscriptions event emitter. */ get targetSubscriptions(): EventEmitter { return this._targetSubscriptions; } /** * Registers an event listener on the message handler. * * Supported events include: * * **RPC-level events:** * - `_rpc_reportSetup:` - Emitted when the debugger setup is reported * - `_rpc_reportConnectedApplicationList:` - Emitted when the list of connected applications is reported * - `_rpc_forwardGetListing:` - Emitted when an application sends a page listing * - `_rpc_applicationConnected:` - Emitted when a new application connects * - `_rpc_applicationDisconnected:` - Emitted when an application disconnects * - `_rpc_applicationUpdated:` - Emitted when an application is updated * - `_rpc_reportConnectedDriverList:` - Emitted when the list of connected drivers is reported * - `_rpc_reportCurrentState:` - Emitted when the current state is reported * * **Target events:** * - `Target.targetCreated` - Emitted when a new target is created (args: error, appIdKey, targetInfo) * - `Target.targetDestroyed` - Emitted when a target is destroyed (args: error, appIdKey, targetInfo) * - `Target.didCommitProvisionalTarget` - Emitted when a provisional target commits (args: error, appIdKey, provisionalTargetInfo) * * **Page events:** * - `Page.frameStoppedLoading` - Emitted when a frame stops loading * - `Page.frameNavigated` - Emitted when a frame navigates * - `Page.frameDetached` - Emitted when a frame is detached * - `Page.loadEventFired` - Emitted when the page load event fires * * **Runtime events:** * - `Runtime.executionContextCreated` - Emitted when an execution context is created (args: error, context) * * **Console events:** * - `Console.messageAdded` - Emitted when a console message is added (args: error, message) * - `Console.messageRepeatCountUpdated` - Emitted when a console message repeat count is updated * - `ConsoleEvent` - Aggregate event for all Console.* events (args: error, params, methodName) * * **Network events:** * - `NetworkEvent` - Aggregate event for all Network.* events (args: error, params, methodName) * * **Timeline events:** * - `Timeline.eventRecorded` - Emitted when a timeline event is recorded (args: error, record) * * **Heap events:** * - `Heap.garbageCollected` - Emitted when garbage collection occurs * * **Message ID events:** * - Any numeric string (message ID) - Emitted for command responses (args: error, result) * * @param event - The event name to listen for. * @param listener - The listener function to call when the event is emitted. * The listener receives (error, ...args) where error may be null/undefined. * @returns This instance for method chaining. */ on(event: string, listener: (...args: any[]) => void): this { this.messageHandler.on(event, listener); return this; } /** * Registers a one-time event listener on the message handler. * The listener will be automatically removed after being called once. * * See {@link RpcClient.on} for a list of supported events. * * @param event - The event name to listen for. * @param listener - The listener function to call when the event is emitted. * The listener receives (error, ...args) where error may be null/undefined. * @returns This instance for method chaining. */ once(event: string, listener: (...args: any[]) => void): this { this.messageHandler.once(event, listener); return this; } /** * Removes an event listener from the message handler. * * See {@link RpcClient.on} for a list of supported events. * * @param event - The event name to stop listening for. * @param listener - The listener function to remove. * @returns This instance for method chaining. */ off(event: string, listener: (...args: any[]) => void): this { this.messageHandler.off(event, listener); return this; } /** * Waits for a target to be created for the specified app and page. * If the target already exists, returns it immediately. Otherwise, * waits up to the configured timeout for the target to be created. * * @param appIdKey - The application identifier key. * @param pageIdKey - The page identifier key. * @returns A promise that resolves to the target ID if found, undefined otherwise. * @throws Error if no target is found after the timeout. */ async waitForTarget(appIdKey: AppIdKey, pageIdKey: PageIdKey): Promise<TargetId | undefined> { let target = this.getTarget(appIdKey, pageIdKey); if (target) { log.debug( `The target '${target}' for app '${appIdKey}' and page '${pageIdKey}' already exists, no need to wait`, ); return target; } // otherwise waiting is necessary to see what the target is const waitMs = Math.max(MIN_WAIT_FOR_TARGET_TIMEOUT_MS, this.pageLoadTimeoutMs || 0); log.debug( `Waiting up to ${waitMs}ms for a target to be created for ` + `app '${appIdKey}' and page '${pageIdKey}'`, ); try { await waitForCondition( () => { target = this.getTarget(appIdKey, pageIdKey); return !_.isEmpty(target); }, { waitMs, intervalMs: WAIT_FOR_TARGET_INTERVAL_MS, }, ); return target; } catch (err: any) { if (!err.message.includes('Condition unmet')) { throw err; } throw new Error( `No targets could be matched for the app '${appIdKey}' and page '${pageIdKey}' after ${waitMs}ms`, ); } } /** * Sends a command to the remote debugger with automatic retry logic * for target-related errors. Handles cases where targets are not yet * available or not supported. * * @param command - The command name to send. * @param opts - Options for the command. * @param waitForResponse - Whether to wait for a response. Defaults to true. * @returns A promise that resolves to the command result or options. */ async send( command: string, opts: RemoteCommandOpts, waitForResponse: boolean = true, ): Promise<any> { const timer = new timing.Timer().start(); try { return await this.sendToDevice(command, opts, waitForResponse); } catch (err: any) { const {appIdKey, pageIdKey} = opts; const messageLc = (err.message || '').toLowerCase(); if (messageLc.includes(NO_TARGET_SUPPORTED_ERROR)) { return await this.sendToDevice(command, opts, waitForResponse); } else if ( appIdKey && NO_TARGET_PRESENT_YET_ERRORS.some((error) => messageLc.includes(error)) ) { await this.waitForTarget(appIdKey, pageIdKey as PageIdKey); return await this.sendToDevice(command, opts, waitForResponse); } throw err; } finally { log.debug(`Sending to Web Inspector took ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`); } } /** * Sends a command directly to the device, handling message routing, * response waiting, and error handling. * * @template TWaitForResponse - Whether to wait for a response. * @param command - The command name to send. * @param opts - Options for the command. * @param waitForResponse - Whether to wait for a response. Defaults to true. * @returns A promise that resolves based on waitForResponse: * - If true: resolves to the response value * - If false: resolves to the full options object */ async sendToDevice<TWaitForResponse extends boolean = true>( command: string, opts: RemoteCommandOpts, waitForResponse: TWaitForResponse = true as TWaitForResponse, ): Promise<TWaitForResponse extends true ? any : RemoteCommandOpts> { return await new B<any>(async (resolve, reject) => { // promise to be resolved whenever remote debugger // replies to our request // keep track of the messages coming and going using a simple sequential id const msgId = this.msgId++; // for target-base communication, everything is wrapped up const wrapperMsgId = this.msgId++; // acknowledge wrapper message this.messageHandler.on(wrapperMsgId.toString(), function (err: Error | null) { if (err) { reject(err); } }); const appIdKey = opts.appIdKey; const pageIdKey = opts.pageIdKey; const targetId = opts.targetId ?? this.getTarget(appIdKey, pageIdKey); // retrieve the correct command to send const fullOpts: RemoteCommandOpts & RemoteCommandId = _.defaults( { connId: this.connId, senderId: this.senderId, targetId, id: msgId.toString(), }, opts, ); let cmd: RawRemoteCommand; try { cmd = this.remoteMessages.getRemoteCommand(command, fullOpts); } catch (err: any) { log.error(err); return reject(err); } const finalCommand: RemoteCommand = { __argument: _.omit(cmd.__argument, ['WIRSocketDataKey']) as any, __selector: cmd.__selector, }; const hasSocketData = _.isPlainObject(cmd.__argument?.WIRSocketDataKey); if (hasSocketData) { // make sure the message being sent has all the information that is needed const socketData = cmd.__argument.WIRSocketDataKey as StringRecord; if (!_.isInteger(socketData.id)) { // ! This must be a number socketData.id = wrapperMsgId; } finalCommand.__argument.WIRSocketDataKey = Buffer.from(JSON.stringify(socketData)); } let messageHandled = true; if (!waitForResponse) { // the promise will be resolved as soon as the socket has been sent messageHandled = false; // do not log receipts this.messageHandler.once(msgId.toString(), (err: Error | null) => { if (err) { // we are not waiting for this, and if it errors it is most likely // a protocol change. Log and check during testing log.error( `Received error from send that is not being waited for (id: ${msgId}): ` + _.truncate(JSON.stringify(err), DATA_LOG_LENGTH), ); // reject, though it is very rare that this will be triggered, since // the promise is resolved directly after send. On the off chance, // though, it will alert of a protocol change. reject(err); } }); } else if (this.messageHandler.listenerCount(cmd.__selector)) { this.messageHandler.prependOnceListener( cmd.__selector, (err: Error | null, ...args: any[]) => { if (err) { return reject(err); } log.debug( `Received response from send (id: ${msgId}): '${_.truncate(JSON.stringify(args), DATA_LOG_LENGTH)}'`, ); resolve(args); }, ); } else if (hasSocketData) { this.messageHandler.once(msgId.toString(), (err: Error | null, value: any) => { if (err) { return reject( new Error(`Remote debugger error with code '${(err as any).code}': ${err.message}`), ); } log.debug( `Received data response from send (id: ${msgId}): '${_.truncate(JSON.stringify(value), DATA_LOG_LENGTH)}'`, ); resolve(value); }); } else { // nothing else is handling things, so just resolve when the message is sent messageHandled = false; } const msg = `Sending '${cmd.__selector}' message` + (appIdKey ? ` to app '${appIdKey}'` : '') + (pageIdKey ? `, page '${pageIdKey}'` : '') + (targetId ? `, target '${targetId}'` : '') + ` (id: ${msgId}): '${command}'`; log.debug(msg); try { await this.sendMessage(finalCommand); if (!messageHandled) { // There are no handlers waiting for a response before resolving, // and no errors sending the message over the socket, so resolve resolve(fullOpts as any); } } catch (err) { return reject(err); } }); } /** * Connects to the remote debugger. Must be implemented by subclasses. * * @throws Error indicating that subclasses must implement this method. */ async connect(): Promise<void> { throw new Error(`Sub-classes need to implement a 'connect' function`); } /** * Disconnects from the remote debugger and cleans up event listeners. */ async disconnect(): Promise<void> { this.messageHandler.removeAllListeners(); } /** * Sends a message to the device. Must be implemented by subclasses. * * @param _command - The command to send. * @throws Error indicating that subclasses must implement this method. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars async sendMessage(_command: RemoteCommand): Promise<void> { throw new Error(`Sub-classes need to implement a 'sendMessage' function`); } /** * Receives data from the device. Must be implemented by subclasses. * * @param _data - The data received from the device. * @throws Error indicating that subclasses must implement this method. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars async receive(_data: any): Promise<void> { throw new Error(`Sub-classes need to implement a 'receive' function`); } /** * Handles the creation of a new target for an application and page. * Initializes the page and waits for readiness if configured. * * @param err - Error if one occurred, undefined otherwise. * @param app - The application identifier key. * @param targetInfo - Information about the created target. */ async addTarget(err: Error | undefined, app: AppIdKey, targetInfo: TargetInfo): Promise<void> { if (_.isNil(targetInfo?.targetId)) { log.info(`Received 'Target.targetCreated' event for app '${app}' with no target. Skipping`); return; } const pendingPageTargetDetails = this._getPendingPageTargetDetails(app, targetInfo); if (!pendingPageTargetDetails) { return; } const {appIdKey, pageIdKey, pageReadinessDetector} = pendingPageTargetDetails; if (!_.isPlainObject(this.targets[appIdKey])) { this.targets[appIdKey] = { lock: new AsyncLock({maxOccupationTime: this._targetCreationTimeoutMs}), } as PagesToTargets; } const timer = new timing.Timer().start(); const adjustPageReadinessDetector = (): PageReadinessDetector | undefined => { if (!pageReadinessDetector) { return; } const elapsedMs = timer.getDuration().asMilliSeconds; if (elapsedMs >= pageReadinessDetector.timeoutMs) { log.warn(`Page '${pageIdKey}' took too long to initialize, skipping readiness check`); return; } return { timeoutMs: pageReadinessDetector.timeoutMs - elapsedMs, readinessDetector: pageReadinessDetector.readinessDetector, }; }; if (targetInfo.isProvisional) { log.debug( `Provisional target created for app '${appIdKey}' and page '${pageIdKey}': '${JSON.stringify(targetInfo)}'`, ); this._provisionedPages.add(pageIdKey); try { await this.targets[appIdKey].lock.acquire(pageIdKey, async () => { let wasInitialized = false; try { wasInitialized = await this._initializePage(appIdKey, pageIdKey, targetInfo.targetId); } finally { if (targetInfo.isPaused) { await this._resumeTarget(appIdKey, pageIdKey, targetInfo.targetId); } else { log.debug( `Provisional target ${targetInfo.targetId}@${appIdKey} is not paused, so not resuming`, ); } } if (wasInitialized) { await this._waitForPageReadiness( appIdKey, pageIdKey, targetInfo.targetId, adjustPageReadinessDetector(), ); } }); } catch (e: any) { log.warn( `Cannot complete the initialization of the provisional target '${targetInfo.targetId}' ` + `after ${timer.getDuration().asMilliSeconds}ms: ${e.message}`, ); } return; } log.debug( `Target created for app '${appIdKey}' and page '${pageIdKey}': ${JSON.stringify(targetInfo)}`, ); if (_.has(this.targets[appIdKey], pageIdKey)) { const existingTarget = this.targets[appIdKey][pageIdKey] as TargetId; log.debug( `There is already a target for this app and page ('${existingTarget}'). ` + `This might cause problems`, ); } this.targets[appIdKey][pageIdKey] = targetInfo.targetId; try { await this.send('Target.setPauseOnStart', { pauseOnStart: true, appIdKey, pageIdKey, }); } catch (e: any) { log.debug( `Cannot setup pause on start for app '${appIdKey}' and page '${pageIdKey}': ${e.message}`, ); } try { await this.targets[appIdKey].lock.acquire(pageIdKey, async () => { let wasInitialized = false; try { if (this._provisionedPages.has(pageIdKey)) { log.debug(`Page '${pageIdKey}' has been already provisioned`); this._provisionedPages.delete(pageIdKey); } else { wasInitialized = await this._initializePage(appIdKey, pageIdKey); } } finally { if (targetInfo.isPaused) { await this._resumeTarget(appIdKey, pageIdKey, targetInfo.targetId); } } if (wasInitialized) { await this._waitForPageReadiness( appIdKey, pageIdKey, targetInfo.targetId, adjustPageReadinessDetector(), ); } }); } catch (e: any) { log.warn(e.message); } finally { // Target creation is happening after provisioning, // which means the above lock would be already released // after provisioning is completed. this._pageSelectionMonitor.emit(ON_PAGE_INITIALIZED_EVENT, appIdKey, pageIdKey); } } /** * Handles updates to provisional targets when they commit. * * @param err - Error if one occurred, undefined otherwise. * @param app - The application identifier key. * @param targetInfo - Information about the provisional target update. */ async updateTarget( err: Error | undefined, app: AppIdKey, targetInfo: ProvisionalTargetInfo, ): Promise<void> { const {oldTargetId, newTargetId} = targetInfo; log.debug( `Target updated for app '${app}'. Old target: '${oldTargetId}', new target: '${newTargetId}'`, ); const appTargetsMap = this.targets[app]; if (!appTargetsMap) { log.warn(`No existing target for app '${app}'. Not sure what to do`); return; } // save this, to be used if/when the existing target is destroyed appTargetsMap.provisional = { oldTargetId, newTargetId, }; } /** * Handles the destruction of a target, including cleanup of provisional targets. * * @param err - Error if one occurred, undefined otherwise. * @param app - The application identifier key. * @param targetInfo - Information about the destroyed target. */ async removeTarget(err: Error | undefined, app: AppIdKey, targetInfo: TargetInfo): Promise<void> { if (_.isNil(targetInfo?.targetId)) { log.debug(`Received 'Target.targetDestroyed' event with no target. Skipping`); return; } log.debug(`Target destroyed for app '${app}': ${targetInfo.targetId}`); // go through the targets and find the one that has a waiting provisional target if (this.targets[app]?.provisional?.oldTargetId === targetInfo.targetId) { const {oldTargetId, newTargetId} = this.targets[app].provisional; delete this.targets[app].provisional; // we do not know the page, so go through and find the existing target const appTargetsMap = this.targets[app]; for (const [page, targetId] of _.toPairs(appTargetsMap)) { if (targetId === oldTargetId) { log.debug( `Found provisional target for app '${app}'. ` + `Old target: '${oldTargetId}', new target: '${newTargetId}'. Updating`, ); appTargetsMap[page] = newTargetId; return; } } log.warn( `Provisional target for app '${app}' found, but no suitable existing target found. This may cause problems`, ); log.warn( `Old target: '${oldTargetId}', new target: '${newTargetId}'. Existing targets: ${JSON.stringify(appTargetsMap)}`, ); } // if there is no waiting provisional target, just get rid of the existing one const targets = this.targets[app]; for (const [page, targetId] of _.toPairs(targets)) { if (targetId === targetInfo.targetId) { delete targets[page]; return; } } log.debug( `Target '${targetInfo.targetId}' deleted for app '${app}', but no such target exists`, ); } /** * Gets the target ID for a specific app and page combination. * * @param appIdKey - The application identifier key. * @param pageIdKey - The page identifier key. * @returns The target ID if found, undefined otherwise. */ getTarget(appIdKey?: AppIdKey, pageIdKey?: PageIdKey): TargetId | undefined { if (!appIdKey || !pageIdKey) { return; } const target = this.targets[appIdKey]?.[pageIdKey]; return target && typeof target === 'string' ? target : undefined; } /** * Selects a page within an application, setting up the Web Inspector session * and waiting for the page to be initialized. Mimics the steps that Desktop * Safari uses to initialize a Web Inspector session. * * @param appIdKey - The application identifier key. * @param pageIdKey - The page identifier key. * @param pageReadinessDetector - Optional detector for determining when the page is ready. */ async selectPage( appIdKey: AppIdKey, pageIdKey: PageIdKey, pageReadinessDetector?: PageReadinessDetector, ): Promise<void> { await this._pageSelectionLock.acquire(toPageSelectionKey(appIdKey, pageIdKey), async () => { this._pendingTargetNotification = {appIdKey, pageIdKey, pageReadinessDetector}; this._provisionedPages.clear(); if (this.getTarget(appIdKey, pageIdKey)) { log.debug(`Page '${pageIdKey}' is already selected for app '${appIdKey}'`); return; } // go through the steps that the Desktop Safari system // goes through to initialize the Web Inspector session const sendOpts = { appIdKey, pageIdKey, }; const timeoutMs = Math.trunc(this._targetCreationTimeoutMs * 1.2); const timer = new timing.Timer().start(); const setupWebview = async () => { // highlight and then un-highlight the webview for (const enabled of [true, false]) { await this.send( 'indicateWebView', Object.assign( { enabled, }, sendOpts, ), false, ); } await this.send('setSenderKey', sendOpts); }; await B.resolve(setupWebview()).timeout( timeoutMs, `Cannot set up page '${pageIdKey}' for app '${appIdKey}' within ${timeoutMs}ms`, ); const msLeft = Math.max(timeoutMs - Math.trunc(timer.getDuration().asMilliSeconds), 1000); log.debug( `Waiting up to ${msLeft}ms for page '${pageIdKey}' of app '${appIdKey}' to be selected`, ); await new Promise<void>((resolve) => { const onPageInitialized = (notifiedAppIdKey: AppIdKey, notifiedPageIdKey: PageIdKey) => { const timeoutHandler = setTimeout(() => { this._pageSelectionMonitor.off(ON_PAGE_INITIALIZED_EVENT, onPageInitialized); log.warn( `Page '${pageIdKey}' for app '${appIdKey}' has not been selected ` + `within ${timer.getDuration().asMilliSeconds}ms. Continuing anyway`, ); resolve(); }, msLeft); if (notifiedAppIdKey === appIdKey && notifiedPageIdKey === pageIdKey) { clearTimeout(timeoutHandler); this._pageSelectionMonitor.off(ON_PAGE_INITIALIZED_EVENT, onPageInitialized); log.debug( `Selected the page ${pageIdKey}@${appIdKey} after ${timer.getDuration().asMilliSeconds}ms`, ); resolve(); } else { log.debug( `Got notified that page ${notifiedPageIdKey}@${notifiedAppIdKey} is initialized, ` + `but we are waiting for ${pageIdKey}@${appIdKey}. Continuing to wait`, ); } }; this._pageSelectionMonitor.on(ON_PAGE_INITIALIZED_EVENT, onPageInitialized); }); }); } /** * Initializes a page by enabling various Web Inspector domains. * Can perform either simple or full initialization based on configuration. * Mimics the steps that Desktop Safari Develop tools uses to initialize * a Web Inspector session. * * @param appIdKey - The application identifier key. * @param pageIdKey - The page identifier key. * @param targetId - Optional target ID. If not provided, will be retrieved from the targets map. * @returns A promise that resolves to true if initialization succeeded, false otherwise. */ private async _initializePage( appIdKey: AppIdKey, pageIdKey: PageIdKey, targetId?: TargetId, ): Promise<boolean> { const sendOpts: RemoteCommandOpts = { appIdKey, pageIdKey, targetId, }; log.debug(`Initializing page '${pageIdKey}' for app '${appIdKey}'`); const timer = new timing.Timer().start(); if (!this.fullPageInitialization) { // The sequence of domains is important for (const domain of [ 'Inspector.enable', 'Page.enable', 'Runtime.enable', 'Network.enable', 'Heap.enable', 'Debugger.enable', 'Console.enable', 'Inspector.initialized', ]) { try { await this.send(domain, sendOpts); } catch (err: any) { log.info(`Cannot enable domain '${domain}' during initialization: ${err.message}`); if (MISSING_TARGET_ERROR_PATTERN.test(err.message)) { return false; } } } log.debug( `Simple initialization of page '${pageIdKey}' for app '${appIdKey}' completed ` + `in ${timer.getDuration().asMilliSeconds}ms`, ); return true; } // The sequence of commands here is important const domainsToOptsMap: Record<string, RemoteCommandOpts> = { 'Inspector.enable': sendOpts, 'Page.enable': sendOpts, 'Runtime.enable': sendOpts, 'Page.getResourceTree': sendOpts, 'Network.enable': sendOpts, 'Network.setResourceCachingDisabled': { disabled: false, ...sendOpts, }, 'DOMStorage.enable': sendOpts, 'Database.enable': sendOpts, 'IndexedDB.enable': sendOpts, 'CSS.enable': sendOpts, 'Heap.enable': sendOpts, 'Memory.enable': sendOpts, 'ApplicationCache.enable': sendOpts, 'ApplicationCache.getFramesWithManifests': sendOpts, 'Timeline.setInstruments': { instruments: ['Timeline', 'ScriptProfiler', 'CPU'], ...sendOpts, }, 'Timeline.setAutoCaptureEnabled': { enabled: false, ...sendOpts, }, 'Debugger.enable': sendOpts, 'Debugger.setBreakpointsActive': { active: true, ...sendOpts, }, 'Debugger.setPauseOnExceptions': { state: 'none', ...sendOpts, }, 'Debugger.setPauseOnAssertions': { enabled: false, ...sendOpts, }, 'Debugger.setAsyncStackTraceDepth': { depth: 200, ...sendOpts, }, 'Debugger.setPauseForInternalScripts': { shouldPause: false, ...sendOpts, }, 'LayerTree.enable': sendOpts, 'Worker.enable': sendOpts, 'Canvas.enable': sendOpts, 'Console.enable': sendOpts, 'DOM.getDocument': sendOpts, 'Console.getLoggingChannels': sendOpts, 'Inspector.initialized': sendOpts, }; for (const [domain, opts] of Object.entries(domainsToOptsMap)) { try { const res = await this.send(domain, opts); if (domain === 'Console.getLoggingChannels') { for (const source of (res?.channels || []).map((entry: {source: any}) => entry.source)) { try { await this.send( 'Console.setLoggingChannelLevel', Object.assign( { source, level: 'verbose', }, sendOpts, ), ); } catch (err: any) { log.info(`Cannot set logging channel level for '${source}': ${err.message}`); if (MISSING_TARGET_ERROR_PATTERN.test(err.message)) { return false; } } } } } catch (err: any) { log.info(`Cannot enable domain '${domain}' during full initialization: ${err.message}`); if (MISSING_TARGET_ERROR_PATTERN.test(err.message)) { return false; } } } log.debug( `Full initialization of page '${pageIdKey}' for app '${appIdKey}' completed ` + `in ${timer.getDuration().asMilliSeconds}ms`, ); return true; } /** * Connects to a specific application and returns its page dictionary. * * @param appIdKey - The application identifier key to connect to. * @returns A promise that resolves to a tuple containing the connected app ID key * and the page dictionary. * @throws Error if a new application connects during the process or if the page * dictionary is empty. */ async selectApp(appIdKey: AppIdKey): Promise<[string, StringRecord]> { return await new B<[string, StringRecord]>((resolve, reject) => { // local callback, temporarily added as callback to // `_rpc_applicationConnected:` remote debugger response // to handle the initial connection const onAppChange = (err: Error | null, dict: StringRecord) => { if (err) { return reject(err); } // from the dictionary returned, get the ids const oldAppIdKey = dict.WIRHostApplicationIdentifierKey; const correctAppIdKey = dict.WIRApplicationIdentifierKey; // if this is a report of a proxy redirect from the remote debugger // we want to update our dictionary and get a new app id if (oldAppIdKey && correctAppIdKey !== oldAppIdKey) { log.debug( `We were notified we might have connected to the wrong app. ` + `Using id ${correctAppIdKey} instead of ${oldAppIdKey}`, ); } reject(new Error(NEW_APP_CONNECTED_ERROR)); }; this.messageHandler.prependOnceListener('_rpc_applicationConnected:', onAppChange); // do the actual connecting to the app (async () => { try { const [connectedAppIdKey, pageDict] = await this.send('connectToApp', {appIdKey}); // sometimes the connect logic happens, but with an empty dictionary // which leads to the remote debugger getting disconnected, and into a loop if (_.isEmpty(pageDict)) { reject(new Error(EMPTY_PAGE_DICTIONARY_ERROR)); } else { resolve([connectedAppIdKey, pageDict]); } } catch (err: any) { log.warn(`Unable to connect to the app: ${err.message}`); reject(err); } finally { this.messageHandler.off('_rpc_applicationConnected:', onAppChange); } })(); }); } /** * Handles execution context creation events by storing the context ID. * * @param err - Error if one occurred, undefined otherwise. * @param context - The execution context information. */ onExecutionContextCreated(err: Error | undefined, context: {id: number}): void { // { id: 2, isPageContext: true, name: '', frameId: '0.1' } // right now we have no way to map contexts to apps/pages // so just store this.contexts.push(context.id); } /** * Handles garbage collection events by logging them. * Garbage collection can affect operation timing. */ onGarbageCollected(): void { // just want to log that this is happening, as it can affect operation log.debug(`Web Inspector garbage collected`); } /** * Handles script parsing events by logging script information. * * @param err - Error if one occurred, undefined otherwise. * @param scriptInfo - Information about the parsed script. */ onScriptParsed(err: Error | undefined, scriptInfo: StringRecord): void { // { scriptId: '13', url: '', startLine: 0, startColumn: 0, endLine: 82, endColumn: 3 } log.debug(`Script parsed: ${JSON.stringify(scriptInfo)}`); } /** * Resumes a paused target. * * @param appIdKey - The application identifier key. * @param pageIdKey - The page identifier key. * @param targetId - The target ID to resume. */ private async _resumeTarget( appIdKey: AppIdKey, pageIdKey: PageIdKey, targetId: TargetId, ): Promise<void> { try { await this.send('Target.resume', { appIdKey, pageIdKey, targetId, }); log.debug(`Successfully resumed the target ${targetId}@${appIdKey}`); } catch (e: any) { log.warn(`Could not resume the target ${targetId}@${appIdKey}: ${e.message}`); } } /** * Waits for a page to be ready by periodically checking the document readyState. * Uses the provided readiness detector to determine when the page is ready. * * @param appIdKey - The application identifier key. * @param pageIdKey - The page identifier key. * @param targetId - The target ID. * @param pageReadinessDetector - The detector for determining page readiness. */ private async _waitForPageReadiness( appIdKey: AppIdKey, pageIdKey: PageIdKey, targetId: TargetId, pageReadinessDetector?: PageReadinessDetector, ): Promise<void> { if (!pageReadinessDetector) { return; } log.debug(`Waiting up to ${pageReadinessDetector.timeoutMs}ms for page readiness`); const timer = new timing.Timer().start(); while (pageReadinessDetector.timeoutMs - timer.getDuration().asMilliSeconds > 0) { let readyState: string; try { const commandTimeoutMs = Math.max( 100, Math.trunc((pageReadinessDetector.timeoutMs - timer.getDuration().asMilliSeconds) * 0.8), ); const rawResult = await B.resolve( this.send('Runtime.evaluate', { expression: 'document.readyState;', returnByValue: true, appIdKey, pageIdKey, targetId, }), ).timeout(commandTimeoutMs); readyState = convertJavascriptEvaluationResult(rawResult); } catch (e: any) { log.debug(`Cannot determine page readiness: ${e.message}`); continue; } if (pageReadinessDetector.readinessDetector(readyState)) { log.info( `Page '${pageIdKey}' for app '${appIdKey}' is ready after ` + `${timer.getDuration().asMilliSeconds}ms`, ); return; } await B.delay(100); } log.warn( `Page '${pageIdKey}' for app '${appIdKey}' is not ready after ` + `${timer.getDuration().asMilliSeconds}ms. Continuing anyway`, ); } /** * Waits for a page to be initialized by acquiring locks on both the page * target lock and the page selection lock. * * @param appIdKey - The application identifier key. * @param pageIdKey - The page identifier key. * @throws Error if no targets are found for the application. */ async waitForPage(appIdKey: AppIdKey, pageIdKey: PageIdKey): Promise<void> { const appTargetsMap = this.targets[appIdKey]; if (!appTargetsMap) { throw new Error(`No targets found for app '${appIdKey}'`); } const lock = appTargetsMap.lock; const timer = new timing.Timer().start(); await Promise.all([ lock.acquire(pageIdKey, async () => await B.delay(0)), this._pageSelectionLock.acquire( toPageSelectionKey(appIdKey, pageIdKey), async () => await B.delay(0), ), ]); const durationMs = timer.getDuration().asMilliSeconds; if (durationMs > 10) { log.debug(`Waited ${durationMs}ms until the page ${pageIdKey}@${appIdKey} is initialized`); } } /** * Gets the pending target details if there is a pending request for the given app. * Filters out non-page target types (e.g., 'frame'). * * @param appId - The application identifier key. * @param targetInfo - Information about the target. * @returns The pending page target details if there's a match, undefined otherwise. */ private _getPendingPageTargetDetails( appId: AppIdKey, targetInfo: TargetInfo, ): PendingPageTargetDetails | undefined { const logInfo = (message: string): undefined => void log.info( `Skipping 'Target.targetCreated' event ${message} for app '${appId}': ${JSON.stringify(targetInfo)}`, ); if (!this._pendingTargetNotification) { return logInfo('with no pending request'); } if (targetInfo.type !== 'page') { // TODO: We'll need to handle 'frame' type for several domains. // Target, Runtime, Debugger and Console should ignore this target for now. // https://github.com/WebKit/WebKit/commit/06f8ad1a5a66f9ffaa33696a5b9fba4f4c65070b#diff-42db8526b5e72dc714b9561e283ef57fbbc3000576a36839ad03df52b5e54667 // https://github.com/appium/appium/issues/21705 return logInfo(`with type '${targetInfo.type}'`); } return this._pendingTargetNotification.appIdKey === appId ? {...this._pendingTargetNotification} : logInfo('with different app id'); } } /** * Creates a unique key for page selection based on app and page IDs. * * @param appIdKey - The application identifier key. * @param pageIdKey - The page identifier key. * @returns A string key combining both identifiers. */ function toPageSelectionKey(appIdKey: AppIdKey, pageIdKey: PageIdKey): string { return `${appIdKey}:${pageIdKey}`; }