UNPKG

appium

Version:

Automation for Apps.

439 lines 19.1 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.determineBiDiHost = determineBiDiHost; exports.onBidiConnection = onBidiConnection; exports.onBidiMessage = onBidiMessage; exports.onBidiServerError = onBidiServerError; exports.cleanupBidiSockets = cleanupBidiSockets; const lodash_1 = __importDefault(require("lodash")); const bluebird_1 = __importDefault(require("bluebird")); const base_driver_1 = require("@appium/base-driver"); const constants_1 = require("./constants"); const ws_1 = __importDefault(require("ws")); const node_os_1 = __importDefault(require("node:os")); const utils_1 = require("./utils"); const MIN_WS_CODE_VAL = 1000; const MAX_WS_CODE_VAL = 1015; const WS_FALLBACK_CODE = 1011; // server encountered an error while fulfilling request const BIDI_EVENTS_MAP = new WeakMap(); const MAX_LOGGED_DATA_LENGTH = 300; /** * Clients cannot use broadcast addresses, like 0.0.0.0 or :: * to create connections. Thus we prefer a hostname if such * address is provided or the actual address of a non-local interface, * in case the host only has one such interface. * * @param address */ function determineBiDiHost(address) { if (!(0, utils_1.isBroadcastIp)(address)) { return address; } const nonLocalInterfaces = (0, utils_1.fetchInterfaces)(address === utils_1.V4_BROADCAST_IP ? 4 : 6) .filter((iface) => !iface.internal); return nonLocalInterfaces.length === 1 ? nonLocalInterfaces[0].address : node_os_1.default.hostname(); } /** * Initialize a new bidi connection and set up handlers * @param ws The websocket connection object * @param req The connection pathname, which might include the session id */ function onBidiConnection(ws, req) { try { const initBiDiSocketFunc = initBidiSocket.bind(this); const { bidiHandlerDriver, bidiHandlerPlugins, proxyClient, send, sendToProxy, logSocketErr } = initBiDiSocketFunc(ws, req); const initBidiSocketHandlersFunc = initBidiSocketHandlers .bind(this); initBidiSocketHandlersFunc(ws, proxyClient, send, sendToProxy, bidiHandlerDriver, bidiHandlerPlugins, logSocketErr); if (proxyClient) { const initBidiProxyHandlersFunc = initBidiProxyHandlers .bind(bidiHandlerDriver); initBidiProxyHandlersFunc(proxyClient, ws, send); } const initBidiEventListenersFunc = initBidiEventListeners .bind(this); initBidiEventListenersFunc(ws, bidiHandlerDriver, bidiHandlerPlugins, send); } catch (err) { this.log.error(err); try { ws.close(); } catch { } } } function wrapCommandWithPlugins(driver, plugins, method, params) { const [moduleName, methodName] = method.split('.'); let next = async () => await driver.executeBidiCommand(method, params); for (const plugin of plugins.filter((p) => p.doesBidiCommandExist(moduleName, methodName))) { next = ((_next) => async () => await plugin.executeBidiCommand(method, params, _next, driver))(next); } return next; } /** * @param data * @param driver * @param plugins */ async function onBidiMessage(data, driver, plugins) { let resMessage; let id = 0; const driverLog = driver.log; const dataTruncated = lodash_1.default.truncate(data.toString(), { length: MAX_LOGGED_DATA_LENGTH }); try { let method; let params; try { ({ id, method, params } = JSON.parse(data.toString('utf8'))); } catch (err) { throw new base_driver_1.errors.InvalidArgumentError(`Could not parse Bidi command '${dataTruncated}': ${err.message}`); } driverLog.info(`--> BIDI message #${id}`); if (!method) { throw new base_driver_1.errors.InvalidArgumentError(`Missing method for BiDi operation in '${dataTruncated}'`); } if (!params) { throw new base_driver_1.errors.InvalidArgumentError(`Missing params for BiDi operation in '${dataTruncated}`); } const executeWrappedCommand = wrapCommandWithPlugins(driver, plugins, method, params); const result = await executeWrappedCommand(); resMessage = { id, type: 'success', result, }; } catch (err) { resMessage = lodash_1.default.has(err, 'bidiErrObject') ? err.bidiErrObject(id) : { id, type: 'error', error: base_driver_1.errors.UnknownError.error(), message: err.message, stacktrace: err.stack, }; } driverLog.info(`<-- BIDI message #${id}`); return resMessage; } /** * Log a bidi server error * @param err */ function onBidiServerError(err) { this.log.warn(`Error from bidi websocket server: ${err}`); } /** * Clean up any bidi sockets associated with session * * @param sessionId */ function cleanupBidiSockets(sessionId) { if (!this.bidiSockets[sessionId]) { return; } try { this.log.debug(`Closing bidi socket(s) associated with session ${sessionId}`); for (const ws of this.bidiSockets[sessionId]) { // 1001 means server is going away ws.close(1001); } } catch { } delete this.bidiSockets[sessionId]; const proxyClient = this.bidiProxyClients[sessionId]; if (!proxyClient) { return; } this.log.debug(`Also closing proxy connection to upstream bidi server`); try { // 1000 means normal closure, which seems correct when Appium is acting as the client proxyClient.close(1000); } catch { } delete this.bidiProxyClients[sessionId]; } // #region Private functions /** * Initialize a new bidi connection * @param ws The websocket connection object * @param req The connection pathname, which might include the session id */ function initBidiSocket(ws, req) { const pathname = req.url; if (!pathname) { throw new Error('Invalid connection request: pathname missing from request'); } const bidiSessionRe = new RegExp(`${constants_1.BIDI_BASE_PATH}/([^/]+)$`); const bidiNoSessionRe = new RegExp(`${constants_1.BIDI_BASE_PATH}/?$`); const sessionMatch = bidiSessionRe.exec(pathname); const noSessionMatch = bidiNoSessionRe.exec(pathname); if (!sessionMatch && !noSessionMatch) { throw new Error(`Got websocket connection for path ${pathname} but didn't know what to do with it. ` + `Ignoring and will close the connection`); } // Let's figure out which driver is going to handle this socket connection. It's either going // to be a driver matching a session id appended to the bidi base path, or this umbrella driver // (if no session id is included in the bidi connection request) let bidiHandlerDriver; let proxyClient = null; const bidiHandlerPlugins = []; if (sessionMatch) { // If we found a session id, see if it matches an active session const sessionId = sessionMatch[1]; bidiHandlerDriver = this.sessions[sessionId]; if (!bidiHandlerDriver) { // The session ID sent in doesn't match an active session; just ignore this socket // connection in that case throw new Error(`Got bidi connection request for session with id ${sessionId} which is closed ` + `or does not exist. Closing the socket connection.`); } const driverName = bidiHandlerDriver.constructor.name; this.log.info(`Bidi websocket connection made for session ${sessionId}`); // store this socket connection for later removal on session deletion. theoretically there // can be multiple sockets per session if (!this.bidiSockets[sessionId]) { this.bidiSockets[sessionId] = []; } this.bidiSockets[sessionId].push(ws); const bidiProxyUrl = bidiHandlerDriver.bidiProxyUrl; if (bidiProxyUrl) { try { new URL(bidiProxyUrl); } catch { throw new Error(`Got request for ${driverName} to proxy bidi connections to upstream socket with ` + `url ${bidiProxyUrl}, but this was not a valid url`); } this.log.info(`Bidi connection for ${driverName} will be proxied to ${bidiProxyUrl}. ` + `Plugins will not handle bidi commands`); proxyClient = new ws_1.default(bidiProxyUrl); this.bidiProxyClients[sessionId] = proxyClient; } else { bidiHandlerPlugins.push(...this.pluginsForSession(sessionId)); } } else { this.log.info('Bidi websocket connection made to main server'); // no need to store the socket connection if it's to the main server since it will just // stay open as long as the server itself is and will close when the server closes. bidiHandlerDriver = this; // eslint-disable-line @typescript-eslint/no-this-alias bidiHandlerPlugins.push(...this.pluginsForSession()); } const driverLog = bidiHandlerDriver.log; const logSocketErr = (err) => { driverLog.warn(err.message); }; // This is a function which wraps the 'send' method on a web socket for two reasons: // 1. Make it async-await friendly // 2. Do some logging if there's a send error const sendFactory = (socket) => { const socketSend = bluebird_1.default.promisify(socket.send, { context: socket }); return async (data) => { try { await assertIsOpen(socket); await socketSend(data); } catch (err) { logSocketErr(err); } }; }; // Construct our send method for sending messages to the client const send = sendFactory(ws); // Construct a conditional send method for proxying messages from the client to an upstream // bidi socket server (e.g. on a browser) const sendToProxy = proxyClient ? sendFactory(proxyClient) : null; return { bidiHandlerDriver, bidiHandlerPlugins, proxyClient, send, sendToProxy, logSocketErr }; } /** * Set up handlers on upstream bidi socket we are proxying to/from * * @param proxyClient - the websocket connection to/from the * upstream socket (the one we're proxying to/from) * @param ws - the websocket connection to/from the client * @param send - a method used to send data to the * client */ function initBidiProxyHandlers(proxyClient, ws, send) { // Set up handlers for events that might come from the upstream bidi socket connection if // we're in proxy mode const driverLog = this.log; // Here we're receiving a message from the upstream socket server. We want to pass it on to // the client proxyClient.on('message', send); // If the upstream socket server closes the connection, should close the connection to the // client as well proxyClient.on('close', (code, reason) => { driverLog.debug(`Upstream bidi socket closed connection (code ${code}, reason: '${reason}'). ` + `Closing proxy connection to client`); const intCode = lodash_1.default.isNumber(code) ? code : parseInt(code, 10); if (lodash_1.default.isNaN(intCode) || intCode < MIN_WS_CODE_VAL || intCode > MAX_WS_CODE_VAL) { driverLog.warn(`Received code ${code} from upstream socket, but this is not a valid ` + `websocket code. Rewriting to ${WS_FALLBACK_CODE} for ws compatibility`); code = WS_FALLBACK_CODE; } ws.close(code, reason); }); proxyClient.on('error', (err) => { driverLog.warn(`Got error on upstream bidi socket connection: ${err.message}`); }); } /** * Set up handlers on the bidi socket connection to the client * * @param ws - the websocket connection to/from the client * @param proxyClient - the websocket connection to/from the * upstream socket (the one we're proxying to/from, if we're proxying) * @param send - a method used to send data to the * client * @param sendToProxy - a method used to send data to the * upstream socket * @param bidiHandlerDriver - the driver * handling the bidi commands * @param bidiHandlerPlugins - plugins that might also handle bidi commands * @param logSocketErr - a special prefixed logger */ function initBidiSocketHandlers(ws, proxyClient, send, sendToProxy, bidiHandlerDriver, bidiHandlerPlugins, logSocketErr) { const driverLog = bidiHandlerDriver.log; // Can't do much with random errors on the connection other than log them ws.on('error', logSocketErr); ws.on('open', () => { driverLog.info('BiDi websocket connection is now open'); }); // Now set up handlers for the various events that might happen on the websocket connection // coming from the client // First is incoming messages from the client ws.on('message', async (data) => { if (proxyClient && sendToProxy) { // if we're meant to proxy to an upstream bidi socket, just do that // TODO trying to determine how this proxying behaviour would interface with plugins is too // complex for now, so just ignore plugins in this case await sendToProxy(data.toString('utf8')); } else { const res = await this.onBidiMessage(data, bidiHandlerDriver, bidiHandlerPlugins); await send(JSON.stringify(res)); } }); // Next consider if the client closes the socket connection on us ws.on('close', (code, reason) => { // Not sure if we need to do anything here if the client closes the websocket connection. // Probably if a session was started via the socket, and the socket closes, we should end the // associated session to free up resources. But otherwise, for sockets attached to existing // sessions, doing nothing is probably right. driverLog.debug(`BiDi socket connection closed (code ${code}, reason: '${reason}')`); // If we're proxying, might as well close the upstream connection and clean it up if (proxyClient) { driverLog.debug('Also closing BiDi proxy socket connection'); proxyClient.close(code, reason); } const eventLogCounts = BIDI_EVENTS_MAP.get(bidiHandlerDriver); if (!lodash_1.default.isEmpty(eventLogCounts)) { driverLog.debug(`BiDi events statistics: ${JSON.stringify(eventLogCounts, null, 2)}`); } }); } /** * Set up bidi event listeners * * @param ws - the websocket connection to/from the client * @param bidiHandlerDriver - the driver * handling the bidi commands * @param send - a method used to send data to the * client */ function initBidiEventListeners(ws, bidiHandlerDriver, bidiHandlerPlugins, send) { // If the driver emits a bidi event that should maybe get sent to the client, check to make // sure the client is subscribed and then pass it on const eventLogCounts = BIDI_EVENTS_MAP.get(bidiHandlerDriver) ?? {}; BIDI_EVENTS_MAP.set(bidiHandlerDriver, eventLogCounts); const eventListenerFactory = (extType, ext) => { const eventListener = async ({ context, method, params = {} }) => { // if the driver didn't specify a context, use the empty context if (!context) { context = ''; } if (!method || !params) { ext.log?.warn(// some old plugins might not have the `log` property `${lodash_1.default.capitalize(extType)} emitted a bidi event that was malformed. Require method and params keys ` + `(with optional context). But instead received: ${lodash_1.default.truncate(JSON.stringify({ context, method, params, }), { length: MAX_LOGGED_DATA_LENGTH })}`); return; } if (ws.readyState !== ws_1.default.OPEN) { // if the websocket is not still 'open', then we can ignore sending these events if (ws.readyState > ws_1.default.OPEN) { // if the websocket is closed or closing, we can remove this listener as well to avoid // leaks. Some old plugin classes might not have the `eventEmitter` property, so use an // existence guard for now. ext.eventEmitter?.removeListener(constants_1.BIDI_EVENT_NAME, eventListener); } return; } const eventSubs = bidiHandlerDriver.bidiEventSubs[method]; if (lodash_1.default.isArray(eventSubs) && eventSubs.includes(context)) { if (method in eventLogCounts) { ++eventLogCounts[method]; } else { ext.log?.debug(// some old plugins might not have the `log` property `<-- BIDI EVENT ${method} (context: '${context}', ` + `params: ${lodash_1.default.truncate(JSON.stringify(params), { length: MAX_LOGGED_DATA_LENGTH })}). ` + `All further similar events won't be logged.`); eventLogCounts[method] = 1; } // now we can send the event onto the socket const ev = { type: 'event', context, method, params }; await send(JSON.stringify(ev)); } }; return eventListener; }; bidiHandlerDriver.eventEmitter.on(constants_1.BIDI_EVENT_NAME, eventListenerFactory('driver', bidiHandlerDriver)); for (const plugin of bidiHandlerPlugins) { // some old plugins might not have the eventEmitter property plugin.eventEmitter?.on(constants_1.BIDI_EVENT_NAME, eventListenerFactory('plugin', plugin)); } } async function assertIsOpen(ws, timeoutMs = 5000) { if (ws.readyState === ws.OPEN) { return ws; } if (ws.readyState > ws.OPEN) { throw new Error(`The BiDi web socket at ${ws.url} is not open`); } let errorListener; let openListener; // The socket is in CONNECTING state. Wait up to `timeoutMs` until it is open try { await new Promise((resolve, reject) => { setTimeout(() => reject(new Error(`The BiDi web socket at ${ws.url} did not ` + `open after ${timeoutMs}ms timeout`)), timeoutMs); ws.once('error', reject); errorListener = reject; ws.once('open', resolve); openListener = resolve; }); } finally { if (errorListener) { ws.off('error', errorListener); } if (openListener) { ws.off('open', openListener); } } return ws; } // #endregion //# sourceMappingURL=bidi-commands.js.map