appium-remote-debugger
Version:
Appium proxy for Remote Debugger protocol
1,026 lines • 47 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.RpcClient = exports.EMPTY_PAGE_DICTIONARY_ERROR = exports.NEW_APP_CONNECTED_ERROR = void 0;
const remote_messages_1 = require("./remote-messages");
const asyncbox_1 = require("asyncbox");
const logger_1 = 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 node_events_1 = require("node:events");
const async_lock_1 = __importDefault(require("async-lock"));
const utils_1 = require("../utils");
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`,
];
exports.NEW_APP_CONNECTED_ERROR = 'New application has connected';
exports.EMPTY_PAGE_DICTIONARY_ERROR = 'Empty page dictionary received';
const ON_PAGE_INITIALIZED_EVENT = 'onPageInitialized';
/**
* 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.
*/
class RpcClient {
messageHandler;
remoteMessages;
connected;
isSafari;
connId;
senderId;
msgId;
udid;
logAllCommunication;
logAllCommunicationHexDump;
socketChunkSize;
webInspectorMaxFrameLength;
fullPageInitialization;
bundleId;
pageLoadTimeoutMs;
platformVersion;
_contexts;
_targets;
_targetSubscriptions;
_pendingTargetNotification;
_targetCreationTimeoutMs;
_provisionedPages;
_pageSelectionLock;
_pageSelectionMonitor;
/**
* @param opts - Options for configuring the RPC client.
*/
constructor(opts = {}) {
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 = 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.pageLoadTimeoutMs = pageLoadTimeoutMs;
this.fullPageInitialization = fullPageInitialization;
this.bundleId = bundleId;
this.platformVersion = platformVersion;
this._contexts = [];
this._targets = {};
this._targetSubscriptions = new node_events_1.EventEmitter();
this._provisionedPages = new Set();
this._pageSelectionLock = new async_lock_1.default();
this._pageSelectionMonitor = new node_events_1.EventEmitter();
this._targetCreationTimeoutMs = targetCreationTimeoutMs;
this.remoteMessages = new remote_messages_1.RemoteMessages();
this.messageHandler = new rpc_message_handler_1.default();
// 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() {
return this._contexts;
}
/**
* Gets the mapping of applications to their pages and targets.
*
* @returns The targets mapping structure.
*/
get targets() {
return this._targets;
}
/**
* Gets whether the client is currently connected.
*
* @returns True if connected, false otherwise.
*/
get isConnected() {
return this.connected;
}
/**
* Sets the connection status.
*
* @param connected - The connection status to set.
*/
set isConnected(connected) {
this.connected = !!connected;
}
/**
* Gets the event emitter for target subscriptions.
*
* @returns The target subscriptions event emitter.
*/
get targetSubscriptions() {
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, listener) {
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, listener) {
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, listener) {
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, pageIdKey) {
let target = this.getTarget(appIdKey, pageIdKey);
if (target) {
logger_1.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);
logger_1.log.debug(`Waiting up to ${waitMs}ms for a target to be created for ` +
`app '${appIdKey}' and page '${pageIdKey}'`);
try {
await (0, asyncbox_1.waitForCondition)(() => {
target = this.getTarget(appIdKey, pageIdKey);
return !lodash_1.default.isEmpty(target);
}, {
waitMs,
intervalMs: WAIT_FOR_TARGET_INTERVAL_MS,
});
return target;
}
catch (err) {
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, opts, waitForResponse = true) {
const timer = new support_1.timing.Timer().start();
try {
return await this.sendToDevice(command, opts, waitForResponse);
}
catch (err) {
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);
return await this.sendToDevice(command, opts, waitForResponse);
}
throw err;
}
finally {
logger_1.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(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++;
// for target-base communication, everything is wrapped up
const wrapperMsgId = this.msgId++;
// acknowledge wrapper message
this.messageHandler.on(wrapperMsgId.toString(), function (err) {
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 = lodash_1.default.defaults({
connId: this.connId,
senderId: this.senderId,
targetId,
id: msgId.toString(),
}, opts);
let cmd;
try {
cmd = this.remoteMessages.getRemoteCommand(command, fullOpts);
}
catch (err) {
logger_1.log.error(err);
return reject(err);
}
const finalCommand = {
__argument: lodash_1.default.omit(cmd.__argument, ['WIRSocketDataKey']),
__selector: cmd.__selector,
};
const hasSocketData = lodash_1.default.isPlainObject(cmd.__argument?.WIRSocketDataKey);
if (hasSocketData) {
// make sure the message being sent has all the information that is needed
const socketData = cmd.__argument.WIRSocketDataKey;
if (!lodash_1.default.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) => {
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.log.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 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, ...args) => {
if (err) {
return reject(err);
}
logger_1.log.debug(`Received response from send (id: ${msgId}): '${lodash_1.default.truncate(JSON.stringify(args), DATA_LOG_LENGTH)}'`);
resolve(args);
});
}
else if (hasSocketData) {
this.messageHandler.once(msgId.toString(), (err, value) => {
if (err) {
return reject(new Error(`Remote debugger error with code '${err.code}': ${err.message}`));
}
logger_1.log.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}'` : '') +
(targetId ? `, target '${targetId}'` : '') +
` (id: ${msgId}): '${command}'`;
logger_1.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);
}
}
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() {
throw new Error(`Sub-classes need to implement a 'connect' function`);
}
/**
* Disconnects from the remote debugger and cleans up event listeners.
*/
async disconnect() {
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) {
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) {
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, app, targetInfo) {
if (lodash_1.default.isNil(targetInfo?.targetId)) {
logger_1.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 (!lodash_1.default.isPlainObject(this.targets[appIdKey])) {
this.targets[appIdKey] = {
lock: new async_lock_1.default({ maxOccupationTime: this._targetCreationTimeoutMs }),
};
}
const timer = new support_1.timing.Timer().start();
const adjustPageReadinessDetector = () => {
if (!pageReadinessDetector) {
return;
}
const elapsedMs = timer.getDuration().asMilliSeconds;
if (elapsedMs >= pageReadinessDetector.timeoutMs) {
logger_1.log.warn(`Page '${pageIdKey}' took too long to initialize, skipping readiness check`);
return;
}
return {
timeoutMs: pageReadinessDetector.timeoutMs - elapsedMs,
readinessDetector: pageReadinessDetector.readinessDetector,
};
};
if (targetInfo.isProvisional) {
logger_1.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 {
logger_1.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) {
logger_1.log.warn(`Cannot complete the initialization of the provisional target '${targetInfo.targetId}' ` +
`after ${timer.getDuration().asMilliSeconds}ms: ${e.message}`);
}
return;
}
logger_1.log.debug(`Target created for app '${appIdKey}' and page '${pageIdKey}': ${JSON.stringify(targetInfo)}`);
if (lodash_1.default.has(this.targets[appIdKey], pageIdKey)) {
const existingTarget = this.targets[appIdKey][pageIdKey];
logger_1.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) {
logger_1.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)) {
logger_1.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) {
logger_1.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, app, targetInfo) {
const { oldTargetId, newTargetId } = targetInfo;
logger_1.log.debug(`Target updated for app '${app}'. Old target: '${oldTargetId}', new target: '${newTargetId}'`);
const appTargetsMap = this.targets[app];
if (!appTargetsMap) {
logger_1.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, app, targetInfo) {
if (lodash_1.default.isNil(targetInfo?.targetId)) {
logger_1.log.debug(`Received 'Target.targetDestroyed' event with no target. Skipping`);
return;
}
logger_1.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 lodash_1.default.toPairs(appTargetsMap)) {
if (targetId === oldTargetId) {
logger_1.log.debug(`Found provisional target for app '${app}'. ` +
`Old target: '${oldTargetId}', new target: '${newTargetId}'. Updating`);
appTargetsMap[page] = newTargetId;
return;
}
}
logger_1.log.warn(`Provisional target for app '${app}' found, but no suitable existing target found. This may cause problems`);
logger_1.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 lodash_1.default.toPairs(targets)) {
if (targetId === targetInfo.targetId) {
delete targets[page];
return;
}
}
logger_1.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, pageIdKey) {
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, pageIdKey, pageReadinessDetector) {
await this._pageSelectionLock.acquire(toPageSelectionKey(appIdKey, pageIdKey), async () => {
this._pendingTargetNotification = { appIdKey, pageIdKey, pageReadinessDetector };
this._provisionedPages.clear();
if (this.getTarget(appIdKey, pageIdKey)) {
logger_1.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 support_1.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 bluebird_1.default.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);
logger_1.log.debug(`Waiting up to ${msLeft}ms for page '${pageIdKey}' of app '${appIdKey}' to be selected`);
await new Promise((resolve) => {
const onPageInitialized = (notifiedAppIdKey, notifiedPageIdKey) => {
const timeoutHandler = setTimeout(() => {
this._pageSelectionMonitor.off(ON_PAGE_INITIALIZED_EVENT, onPageInitialized);
logger_1.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);
logger_1.log.debug(`Selected the page ${pageIdKey}@${appIdKey} after ${timer.getDuration().asMilliSeconds}ms`);
resolve();
}
else {
logger_1.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.
*/
async _initializePage(appIdKey, pageIdKey, targetId) {
const sendOpts = {
appIdKey,
pageIdKey,
targetId,
};
logger_1.log.debug(`Initializing page '${pageIdKey}' for app '${appIdKey}'`);
const timer = new support_1.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) {
logger_1.log.info(`Cannot enable domain '${domain}' during initialization: ${err.message}`);
if (MISSING_TARGET_ERROR_PATTERN.test(err.message)) {
return false;
}
}
}
logger_1.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 = {
'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) => entry.source)) {
try {
await this.send('Console.setLoggingChannelLevel', Object.assign({
source,
level: 'verbose',
}, sendOpts));
}
catch (err) {
logger_1.log.info(`Cannot set logging channel level for '${source}': ${err.message}`);
if (MISSING_TARGET_ERROR_PATTERN.test(err.message)) {
return false;
}
}
}
}
}
catch (err) {
logger_1.log.info(`Cannot enable domain '${domain}' during full initialization: ${err.message}`);
if (MISSING_TARGET_ERROR_PATTERN.test(err.message)) {
return false;
}
}
}
logger_1.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) {
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.log.debug(`We were notified we might have connected to the wrong app. ` +
`Using id ${correctAppIdKey} instead of ${oldAppIdKey}`);
}
reject(new Error(exports.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 (lodash_1.default.isEmpty(pageDict)) {
reject(new Error(exports.EMPTY_PAGE_DICTIONARY_ERROR));
}
else {
resolve([connectedAppIdKey, pageDict]);
}
}
catch (err) {
logger_1.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, 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);
}
/**
* Handles garbage collection events by logging them.
* Garbage collection can affect operation timing.
*/
onGarbageCollected() {
// just want to log that this is happening, as it can affect operation
logger_1.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, scriptInfo) {
// { scriptId: '13', url: '', startLine: 0, startColumn: 0, endLine: 82, endColumn: 3 }
logger_1.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.
*/
async _resumeTarget(appIdKey, pageIdKey, targetId) {
try {
await this.send('Target.resume', {
appIdKey,
pageIdKey,
targetId,
});
logger_1.log.debug(`Successfully resumed the target ${targetId}@${appIdKey}`);
}
catch (e) {
logger_1.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.
*/
async _waitForPageReadiness(appIdKey, pageIdKey, targetId, pageReadinessDetector) {
if (!pageReadinessDetector) {
return;
}
logger_1.log.debug(`Waiting up to ${pageReadinessDetector.timeoutMs}ms for page readiness`);
const timer = new support_1.timing.Timer().start();
while (pageReadinessDetector.timeoutMs - timer.getDuration().asMilliSeconds > 0) {
let readyState;
try {
const commandTimeoutMs = Math.max(100, Math.trunc((pageReadinessDetector.timeoutMs - timer.getDuration().asMilliSeconds) * 0.8));
const rawResult = await bluebird_1.default.resolve(this.send('Runtime.evaluate', {
expression: 'document.readyState;',
returnByValue: true,
appIdKey,
pageIdKey,
targetId,
})).timeout(commandTimeoutMs);
readyState = (0, utils_1.convertJavascriptEvaluationResult)(rawResult);
}
catch (e) {
logger_1.log.debug(`Cannot determine page readiness: ${e.message}`);
continue;
}
if (pageReadinessDetector.readinessDetector(readyState)) {
logger_1.log.info(`Page '${pageIdKey}' for app '${appIdKey}' is ready after ` +
`${timer.getDuration().asMilliSeconds}ms`);
return;
}
await bluebird_1.default.delay(100);
}
logger_1.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, pageIdKey) {
const appTargetsMap = this.targets[appIdKey];
if (!appTargetsMap) {
throw new Error(`No targets found for app '${appIdKey}'`);
}
const lock = appTargetsMap.lock;
const timer = new support_1.timing.Timer().start();
await Promise.all([
lock.acquire(pageIdKey, async () => await bluebird_1.default.delay(0)),
this._pageSelectionLock.acquire(toPageSelectionKey(appIdKey, pageIdKey), async () => await bluebird_1.default.delay(0)),
]);
const durationMs = timer.getDuration().asMilliSeconds;
if (durationMs > 10) {
logger_1.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.
*/
_getPendingPageTargetDetails(appId, targetInfo) {
const logInfo = (message) => void logger_1.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');
}
}
exports.RpcClient = RpcClient;
/**
* 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, pageIdKey) {
return `${appIdKey}:${pageIdKey}`;
}
//# sourceMappingURL=rpc-client.js.map