UNPKG

detox

Version:

E2E tests and automation for mobile

295 lines (241 loc) 7.75 kB
/* eslint @typescript-eslint/no-unused-vars: ["error", { "args": "none" }] */ // @ts-nocheck const _ = require('lodash'); const WebSocket = require('ws'); const DetoxInternalError = require('../errors/DetoxInternalError'); const DetoxRuntimeError = require('../errors/DetoxRuntimeError'); const Deferred = require('../utils/Deferred'); const log = require('../utils/logger').child({ cat: 'ws-client,ws' }); const InflightRequest = require('./InflightRequest'); const DEFAULT_SEND_OPTIONS = { timeout: 0, }; class AsyncWebSocket { constructor(url) { this._url = url; this._ws = null; this._eventCallbacks = {}; this._messageIdCounter = 0; this._opening = null; this._closing = null; this._abortedMessageIds = new Set(); this.inFlightPromises = {}; } async open() { if (this._ws) { throw new DetoxInternalError(`Cannot open an already ${this.status} web socket.`); } this._opening = new Deferred(); try { this._ws = new WebSocket(this._url); this._ws.onopen = this._onOpen.bind(this); this._ws.onerror = this._onError.bind(this); this._ws.onmessage = this._onMessage.bind(this); this._ws.onclose = this._onClose.bind(this); } catch (e) { this._unlinkSocket(); throw new DetoxRuntimeError({ message: 'Unexpected error occurred when opening a web socket connection.\nSee the error details below.', hint: DetoxRuntimeError.reportIssue, debugInfo: e, }); } return this._opening.promise; } async close() { if (!this._ws) { return; } if (this._closing) { throw new DetoxInternalError('Detected an attempt to close an already closing or closed web socket.'); } const closing = this._closing = new Deferred(); try { this._ws.close(); } catch (error) { this._onError({ error }); } return closing.promise; } async send(message, options = DEFAULT_SEND_OPTIONS) { if (!this.isOpen) { throw new DetoxRuntimeError({ message: 'Cannot send a message over the closed web socket. See the payload below:', hint: DetoxRuntimeError.reportIssue, debugInfo: message, }); } if (!_.isNumber(message.messageId)) { message.messageId = this._messageIdCounter++; } const messageId = message.messageId; const inFlight = this.inFlightPromises[messageId] = new InflightRequest(message).withTimeout(options.timeout); this.handleMultipleNonAtomicPendingActions(); const payload = JSON.stringify(message); log.trace({ data: payload }, 'send message'); this._ws.send(payload); return inFlight.promise; } handleMultipleNonAtomicPendingActions() { const pendingNonAtomicRequests = this.getNonAtomicPendingActions(); for (const inflight of pendingNonAtomicRequests) { inflight.reject(new DetoxRuntimeError({ message: 'Detox has detected multiple interactions taking place simultaneously. Have you forgotten to apply an await over one of the Detox actions in your test code?', })); } } getNonAtomicPendingActions() { const remaining = Object.keys(this.inFlightPromises).map((key) => { return this.inFlightPromises[key]; }).filter(item => { return item.message.isAtomic === true; }); return remaining.length > 1 ? remaining : []; } setEventCallback(event, callback) { if (_.isEmpty(this._eventCallbacks[event])) { this._eventCallbacks[event] = [callback]; } else { this._eventCallbacks[event].push(callback); } } resetInFlightPromises() { for (const messageId of _.keys(this.inFlightPromises)) { const inFlight = this.inFlightPromises[messageId]; inFlight.clearTimeout(); delete this.inFlightPromises[messageId]; this._abortedMessageIds.add(+messageId); } } // TODO [2024-12-01]: handle this leaked abstraction some day hasPendingActions() { return _.some(this.inFlightPromises, p => p.message.type !== 'currentStatus'); } rejectAll(error) { const hasPendingActions = this.hasPendingActions(); const inFlightPromises = _.values(this.inFlightPromises); this.resetInFlightPromises(); for (const inflight of inFlightPromises) { inflight.reject(error); } if (!hasPendingActions) { log.error({ error }); } } get isOpen() { return this.status === 'open'; } get status() { if (!this._ws) { return 'non-initialized'; } switch (this._ws.readyState) { case WebSocket.CLOSED: return 'closed'; case WebSocket.CLOSING: return 'closing'; case WebSocket.CONNECTING: return 'opening'; case WebSocket.OPEN: return 'open'; /* istanbul ignore next */ default: return undefined; } } /** * @param {WebSocket.OpenEvent} event * @private */ _onOpen(event) { log.trace(`opened web socket to: ${this._url}`); this._opening.resolve(); this._opening = null; } /** * @param {Error} event.error * @private */ _onError(event) { const { error } = event; if (this._opening && this._opening.isPending()) { this._opening.reject(new DetoxRuntimeError({ message: 'Failed to open a connection to the Detox server.', debugInfo: error, noStack: true, })); return this._unlinkSocket(); } if (this._closing && this._closing.isPending()) { this._closing.reject(new DetoxRuntimeError({ message: 'Failed to close a connection to the Detox server.', debugInfo: error, noStack: true, })); return this._unlinkSocket(); } this.rejectAll(new DetoxRuntimeError({ message: 'Failed to deliver the message to the Detox server:', debugInfo: error, noStack: true, })); } /** * * @param {WebSocket.MessageEvent} event * @private */ _onMessage(event) { const data = event && event.data || null; try { log.trace({ data }, 'get message'); const json = JSON.parse(data); if (!json || !json.type) { throw new DetoxRuntimeError('Empty or non-typed message received over the web socket.'); } let handled = false; if (this.inFlightPromises.hasOwnProperty(json.messageId)) { this.inFlightPromises[json.messageId].resolve(json); delete this.inFlightPromises[json.messageId]; handled = true; } if (this._eventCallbacks.hasOwnProperty(json.type)) { for (const callback of this._eventCallbacks[json.type]) { callback(json); } handled = true; } if (!handled) { if (this._abortedMessageIds.has(json.messageId)) { log.debug({ messageId: json.messageId }, `late response`); } else { throw new DetoxRuntimeError('Unexpected message received over the web socket: ' + json.type); } } } catch (error) { this.rejectAll(new DetoxRuntimeError({ message: 'Unexpected error on an attempt to handle the response received over the web socket.', hint: 'Examine the inner error:\n\n' + DetoxRuntimeError.format(error) + '\n\nThe payload was:', debugInfo: data, })); } } /** * @param {WebSocket.CloseEvent | null} event * @private */ _onClose(event) { if (this._closing) { this._closing.resolve(); } this._unlinkSocket(); } _unlinkSocket() { if (this._ws) { this._ws.onopen = null; this._ws.onerror = null; this._ws.onmessage = null; this._ws.onclose = null; this._ws = null; } this._opening = null; this._closing = null; } } module.exports = AsyncWebSocket;