UNPKG

appium-remote-debugger

Version:
594 lines 26.7 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const remote_messages_1 = __importDefault(require("./remote-messages")); const asyncbox_1 = require("asyncbox"); const logger_1 = __importDefault(require("../logger")); const lodash_1 = __importDefault(require("lodash")); const bluebird_1 = __importDefault(require("bluebird")); const rpc_message_handler_1 = __importDefault(require("./rpc-message-handler")); const support_1 = require("@appium/support"); const DATA_LOG_LENGTH = { length: 200 }; const WAIT_FOR_TARGET_TIMEOUT = 10000; const WAIT_FOR_TARGET_INTERVAL = 1000; const MIN_PLATFORM_FOR_TARGET_BASED = '12.2'; // `Target.exists` protocol method was removed from WebKit in 13.4 const MIN_PLATFORM_NO_TARGET_EXISTS = '13.4'; function isTargetBased(isSafari, platformVersion) { // On iOS 12.2 the messages get sent through the Target domain // On iOS 13.0+, WKWebView also needs to follow the Target domain, // so here only check the target OS version as the default behaviour. const isHighVersion = support_1.util.compareVersions(platformVersion, '>=', MIN_PLATFORM_FOR_TARGET_BASED); logger_1.default.debug(`Checking which communication style to use (${isSafari ? '' : 'non-'}Safari on platform version '${platformVersion}')`); logger_1.default.debug(`Platform version equal or higher than '${MIN_PLATFORM_FOR_TARGET_BASED}': ${isHighVersion}`); return isHighVersion; } class RpcClient { constructor(opts = {}) { this._targets = []; this._shouldCheckForTarget = !!opts.shouldCheckForTarget; const { bundleId, platformVersion = {}, isSafari = true, logAllCommunication = false, logAllCommunicationHexDump = false, webInspectorMaxFrameLength, socketChunkSize, fullPageInitialization = false, udid, } = opts; this.isSafari = isSafari; this.isConnected = false; this.connId = support_1.util.uuidV4(); this.senderId = support_1.util.uuidV4(); this.msgId = 0; this.udid = udid; this.logAllCommunication = logAllCommunication; this.logAllCommunicationHexDump = logAllCommunicationHexDump; this.socketChunkSize = socketChunkSize; this.webInspectorMaxFrameLength = webInspectorMaxFrameLength; this.fullPageInitialization = fullPageInitialization; this.bundleId = bundleId; this.platformVersion = platformVersion; this._contexts = []; this._targets = {}; // start with a best guess for the protocol this.isTargetBased = isTargetBased(isSafari, this.platformVersion); } get contexts() { return this._contexts; } get needsTarget() { return this.shouldCheckForTarget && this.isTargetBased; } get targets() { return this._targets; } get shouldCheckForTarget() { return this._shouldCheckForTarget; } set shouldCheckForTarget(shouldCheckForTarget) { this._shouldCheckForTarget = !!shouldCheckForTarget; } get isConnected() { return this.connected; } set isConnected(connected) { this.connected = !!connected; } on(event, listener) { // @ts-ignore messageHandler must be defined here this.messageHandler.on(event, listener); return this; } once(event, listener) { // @ts-ignore messageHandler must be defined here this.messageHandler.once(event, listener); return this; } off(event, listener) { // @ts-ignore messageHandler must be defined here this.messageHandler.off(event, listener); return this; } set isTargetBased(isTargetBased) { logger_1.default.warn(`Setting communication protocol: using ${isTargetBased ? 'Target-based' : 'full Web Inspector protocol'} communication`); this._isTargetBased = isTargetBased; if (!this.remoteMessages) { this.remoteMessages = new remote_messages_1.default(isTargetBased); } else { this.remoteMessages.isTargetBased = isTargetBased; } if (!this.messageHandler) { this.messageHandler = new rpc_message_handler_1.default(isTargetBased); // 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)); } else { this.messageHandler.isTargetBased = isTargetBased; } } get isTargetBased() { return this._isTargetBased; } /** * * @param {string} appIdKey * @param {string} pageIdKey * @param {boolean} [force] * @returns {Promise<void>} */ async waitForTarget(appIdKey, pageIdKey, force = false) { if (!force && !this.needsTarget) { return; } if (this.getTarget(appIdKey, pageIdKey)) { return; } // otherwise waiting is necessary to see what the target is try { await (0, asyncbox_1.waitForCondition)(() => !lodash_1.default.isEmpty(this.getTarget(appIdKey, pageIdKey)), { waitMs: WAIT_FOR_TARGET_TIMEOUT, intervalMs: WAIT_FOR_TARGET_INTERVAL, error: 'No targets found, unable to communicate with device', }); } catch (err) { if (!err.message.includes('Condition unmet')) { throw err; } throw new Error('No targets found, unable to communicate with device'); } } /** * * @param {string} command * @param {Record<string, any>} [opts] * @param {boolean} [waitForResponse] * @returns {Promise<any>} */ async send(command, opts = {}, waitForResponse = true) { const timer = new support_1.timing.Timer().start(); const { appIdKey, pageIdKey } = opts; try { if (!lodash_1.default.isEmpty(appIdKey) && !lodash_1.default.isEmpty(pageIdKey)) { await this.waitForTarget(appIdKey, pageIdKey); } return await this.sendToDevice(command, opts, waitForResponse); } catch (err) { let { message = '' } = err; message = message.toLowerCase(); if (message.includes(`'target' domain was not found`)) { logger_1.default.info('The target device does not support Target based communication. ' + 'Will follow non-target based communication.'); this.isTargetBased = false; return await this.sendToDevice(command, opts, waitForResponse); } else if (message.includes(`domain was not found`) || message.includes(`some arguments of method`) || message.includes(`missing target`)) { this.isTargetBased = true; await this.waitForTarget(appIdKey, pageIdKey); return await this.sendToDevice(command, opts, waitForResponse); } throw err; } finally { logger_1.default.debug(`Sending to Web Inspector took ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`); } } /** * * @param {string} command * @param {Record<string, any>} opts * @param {boolean} [waitForResponse] * @returns {Promise<any>} */ async sendToDevice(command, opts = {}, waitForResponse = true) { return await new bluebird_1.default(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++; let wrapperMsgId = msgId; if (this.isTargetBased) { // for target-base communication, everything is wrapped up wrapperMsgId = this.msgId++; // acknowledge wrapper message // @ts-ignore messageHandler must be defined this.messageHandler.on(wrapperMsgId.toString(), function (err) { if (err) { reject(err); } }); } const appIdKey = opts.appIdKey; const pageIdKey = opts.pageIdKey; const targetId = this.getTarget(appIdKey, pageIdKey); // retrieve the correct command to send const fullOpts = lodash_1.default.defaults({ connId: this.connId, senderId: this.senderId, targetId, id: msgId, }, opts); // @ts-ignore remoteMessages must be defined const cmd = this.remoteMessages.getRemoteCommand(command, fullOpts); if (cmd?.__argument?.WIRSocketDataKey) { // make sure the message being sent has all the information that is needed if (lodash_1.default.isNil(cmd.__argument.WIRSocketDataKey.id)) { cmd.__argument.WIRSocketDataKey.id = wrapperMsgId; } cmd.__argument.WIRSocketDataKey = Buffer.from(JSON.stringify(cmd.__argument.WIRSocketDataKey)); } let messageHandled = true; if (!waitForResponse) { // the promise will be resolved as soon as the socket has been sent messageHandled = false; // do not log receipts // @ts-ignore messageHandler must be defined this.messageHandler.once(msgId.toString(), function (err) { if (err) { // we are not waiting for this, and if it errors it is most likely // a protocol change. Log and check during testing logger_1.default.error(`Received error from send that is not being waited for (id: ${msgId}): '${lodash_1.default.truncate(JSON.stringify(err), DATA_LOG_LENGTH)}'`); // reject, though it is very rare that this will be triggered, since // the promise is resolved directlty after send. On the off chance, // though, it will alert of a protocol change. reject(err); } }); // @ts-ignore messageHandler must be defined } else if (this.messageHandler.listeners(cmd.__selector).length) { // @ts-ignore messageHandler must be defined this.messageHandler.prependOnceListener(cmd.__selector, function (err, ...args) { if (err) { return reject(err); } logger_1.default.debug(`Received response from send (id: ${msgId}): '${lodash_1.default.truncate(JSON.stringify(args), DATA_LOG_LENGTH)}'`); resolve(args); }); } else if (cmd?.__argument?.WIRSocketDataKey) { // @ts-ignore messageHandler must be defined this.messageHandler.once(msgId.toString(), function (err, value) { if (err) { return reject(new Error(`Remote debugger error with code '${err.code}': ${err.message}`)); } logger_1.default.debug(`Received data response from send (id: ${msgId}): '${lodash_1.default.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}'` : '') + (this.needsTarget && targetId ? `, target '${targetId}'` : '') + ` (id: ${msgId}): '${command}'`; logger_1.default.debug(msg); try { const res = await this.sendMessage(cmd); if (!messageHandled) { // There are no handlers waiting for a response before resolving, // and no errors sending the message over the socket, so resolve resolve(res); } } catch (err) { return reject(err); } }); } async connect() { throw new Error(`Sub-classes need to implement a 'connect' function`); } async disconnect() { this.messageHandler?.removeAllListeners(); } // eslint-disable-next-line @typescript-eslint/no-unused-vars async sendMessage(command) { throw new Error(`Sub-classes need to implement a 'sendMessage' function`); } async receive( /* data */) { throw new Error(`Sub-classes need to implement a 'receive' function`); } /** * * @param {Error?} err * @param {string} app * @param {Record<string, any>} targetInfo * @returns {void} */ addTarget(err, app, targetInfo) { if (lodash_1.default.isNil(targetInfo?.targetId)) { logger_1.default.warn(`Received 'Target.targetCreated' event for app '${app}' with no target. Skipping`); return; } if (lodash_1.default.isEmpty(this.pendingTargetNotification) && !targetInfo.isProvisional) { logger_1.default.warn(`Received 'Target.targetCreated' event for app '${app}' with no pending request: ${JSON.stringify(targetInfo)}`); return; } if (targetInfo.isProvisional) { logger_1.default.debug(`Provisional target created for app '${app}', '${targetInfo.targetId}'. Ignoring until target update event`); return; } // @ts-ignore this.pendingTargetNotification must be defined here const [appIdKey, pageIdKey] = this.pendingTargetNotification; logger_1.default.debug(`Target created for app '${appIdKey}' and page '${pageIdKey}': ${JSON.stringify(targetInfo)}`); if (lodash_1.default.has(this.targets[appIdKey], pageIdKey)) { logger_1.default.debug(`There is already a target for this app and page ('${this.targets[appIdKey][pageIdKey]}'). This might cause problems`); } this.targets[app] = this.targets[app] || {}; this.targets[appIdKey][pageIdKey] = targetInfo.targetId; } /** * * @param {Error?} err * @param {string} app * @param {string} oldTargetId * @param {string} newTargetId * @returns {void} */ updateTarget(err, app, oldTargetId, newTargetId) { logger_1.default.debug(`Target updated for app '${app}'. Old target: '${oldTargetId}', new target: '${newTargetId}'`); if (!this.targets[app]) { logger_1.default.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 this.targets[app].provisional = { oldTargetId, newTargetId, }; } /** * * @param {Error?} err * @param {string} app * @param {Record<string, any>} targetInfo * @returns {void} */ removeTarget(err, app, targetInfo) { if (lodash_1.default.isNil(targetInfo?.targetId)) { logger_1.default.debug(`Received 'Target.targetDestroyed' event with no target. Skipping`); return; } logger_1.default.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 targets = this.targets[app]; for (const [page, targetId] of lodash_1.default.toPairs(targets)) { if (targetId === oldTargetId) { logger_1.default.debug(`Found provisional target for app '${app}'. Old target: '${oldTargetId}', new target: '${newTargetId}'. Updating`); targets[page] = newTargetId; return; } } logger_1.default.warn(`Provisional target for app '${app}' found, but no suitable existing target found. This may cause problems`); logger_1.default.warn(`Old target: '${oldTargetId}', new target: '${newTargetId}'. Existing targets: ${JSON.stringify(targets)}`); } // if there is no waiting provisional target, just get rid of the existing one const targets = this.targets[app]; for (const [page, targetId] of lodash_1.default.toPairs(targets)) { if (targetId === targetInfo.targetId) { delete targets[page]; return; } } logger_1.default.debug(`Target '${targetInfo.targetId}' deleted for app '${app}', but no such target exists`); } /** * @param {string} appIdKey * @param {string} pageIdKey * @returns {any} */ getTarget(appIdKey, pageIdKey) { return (this.targets[appIdKey] || {})[pageIdKey]; } /** * @param {string} appIdKey * @param {string} pageIdKey * @returns {Promise<void>} */ async selectPage(appIdKey, pageIdKey) { /** @type {[string, string]} */ this.pendingTargetNotification = [appIdKey, pageIdKey]; this.shouldCheckForTarget = false; // go through the steps that the Desktop Safari system // goes through to initialize the Web Inspector session const sendOpts = { appIdKey, pageIdKey, }; // 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); logger_1.default.debug('Sender key set'); if (this.isTargetBased && support_1.util.compareVersions(this.platformVersion, '<', MIN_PLATFORM_NO_TARGET_EXISTS)) { await this.send('Target.exists', sendOpts, false); } this.shouldCheckForTarget = true; if (this.fullPageInitialization) { await this.initializePageFull(appIdKey, pageIdKey); } else { await this.initializePage(appIdKey, pageIdKey); } } /** * Perform the minimal initialization to get the Web Inspector working * @param {string} appIdKey * @param {string} pageIdKey * @returns {Promise<void>} */ async initializePage(appIdKey, pageIdKey) { const sendOpts = { appIdKey, pageIdKey, }; await this.send('Inspector.enable', sendOpts, false); await this.send('Page.enable', sendOpts, false); // go through the tasks to initialize await this.send('Network.enable', sendOpts, false); await this.send('Runtime.enable', sendOpts, false); await this.send('Heap.enable', sendOpts, false); await this.send('Debugger.enable', sendOpts, false); await this.send('Console.enable', sendOpts, false); await this.send('Inspector.initialized', sendOpts, false); } /** * Mimic every step that Desktop Safari Develop tools uses to initialize a * Web Inspector session * * @param {string} appIdKey * @param {string} pageIdKey * @returns {Promise<void>} */ async initializePageFull(appIdKey, pageIdKey) { const sendOpts = { appIdKey, pageIdKey, }; await this.send('Inspector.enable', sendOpts, false); await this.send('Page.enable', sendOpts, false); // go through the tasks to initialize await this.send('Page.getResourceTree', sendOpts, false); await this.send('Network.enable', sendOpts, false); await this.send('Network.setResourceCachingDisabled', Object.assign({ disabled: false, }, sendOpts), false); await this.send('DOMStorage.enable', sendOpts, false); await this.send('Database.enable', sendOpts, false); await this.send('IndexedDB.enable', sendOpts, false); await this.send('CSS.enable', sendOpts, false); await this.send('Runtime.enable', sendOpts, false); await this.send('Heap.enable', sendOpts, false); await this.send('Memory.enable', sendOpts, false); await this.send('ApplicationCache.enable', sendOpts, false); await this.send('ApplicationCache.getFramesWithManifests', sendOpts, false); await this.send('Timeline.setInstruments', Object.assign({ instruments: ['Timeline', 'ScriptProfiler', 'CPU'], }, sendOpts), false); await this.send('Timeline.setAutoCaptureEnabled', Object.assign({ enabled: false, }, sendOpts), false); await this.send('Debugger.enable', sendOpts, false); await this.send('Debugger.setBreakpointsActive', Object.assign({ active: true, }, sendOpts), false); await this.send('Debugger.setPauseOnExceptions', Object.assign({ state: 'none', }, sendOpts), false); await this.send('Debugger.setPauseOnAssertions', Object.assign({ enabled: false, }, sendOpts), false); await this.send('Debugger.setAsyncStackTraceDepth', Object.assign({ depth: 200, }, sendOpts), false); await this.send('Debugger.setPauseForInternalScripts', Object.assign({ shouldPause: false, }, sendOpts), false); await this.send('LayerTree.enable', sendOpts, false); await this.send('Worker.enable', sendOpts, false); await this.send('Canvas.enable', sendOpts, false); await this.send('Console.enable', sendOpts, false); await this.send('DOM.getDocument', sendOpts, false); const loggingChannels = await this.send('Console.getLoggingChannels', sendOpts); for (const source of (loggingChannels.channels || []).map((entry) => entry.source)) { await this.send('Console.setLoggingChannelLevel', Object.assign({ source, level: 'verbose', }, sendOpts), false); } await this.send('Inspector.initialized', sendOpts, false); } /** * * @param {string} appIdKey * @returns {Promise<[string, Record<string, any>]>} */ async selectApp(appIdKey) { return await new bluebird_1.default((resolve, reject) => { // local callback, temporarily added as callback to // `_rpc_applicationConnected:` remote debugger response // to handle the initial connection const onAppChange = (err, dict) => { 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) { logger_1.default.debug(`We were notified we might have connected to the wrong app. ` + `Using id ${correctAppIdKey} instead of ${oldAppIdKey}`); } reject(new Error('New application has connected')); }; // @ts-ignore messageHandler must be defined this.messageHandler.prependOnceListener('_rpc_applicationConnected:', onAppChange); // do the actual connecting to the app return (async () => { let pageDict, connectedAppIdKey; try { ([connectedAppIdKey, pageDict] = await this.send('connectToApp', { appIdKey })); } catch (err) { logger_1.default.warn(`Unable to connect to app: ${err.message}`); reject(err); } // sometimes the connect logic happens, but with an empty dictionary // which leads to the remote debugger getting disconnected, and into a loop if (lodash_1.default.isEmpty(pageDict)) { let msg = 'Empty page dictionary received'; logger_1.default.debug(msg); reject(new Error(msg)); } else { resolve([connectedAppIdKey, pageDict]); } })(); }); } /** * * @param {Error?} err * @param {Record<string, any>} context */ onExecutionContextCreated(err, context) { // { 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); } onGarbageCollected() { // just want to log that this is happening, as it can affect opertion logger_1.default.debug(`Web Inspector garbage collected`); } /** * * @param {Error?} err * @param {Record<string, any>} scriptInfo */ onScriptParsed(err, scriptInfo) { // { scriptId: '13', url: '', startLine: 0, startColumn: 0, endLine: 82, endColumn: 3 } logger_1.default.debug(`Script parsed: ${JSON.stringify(scriptInfo)}`); } } exports.default = RpcClient; //# sourceMappingURL=rpc-client.js.map