detox
Version:
E2E tests and automation for mobile
398 lines (327 loc) • 12.4 kB
JavaScript
// @ts-nocheck
const util = require('util');
const _ = require('lodash');
const { deserializeError } = require('serialize-error');
const DetoxInternalError = require('../errors/DetoxInternalError');
const DetoxRuntimeError = require('../errors/DetoxRuntimeError');
const failedToReachTheApp = require('../errors/longreads/failedToReachTheApp');
const Deferred = require('../utils/Deferred');
const { asError, createErrorWithUserStack, replaceErrorStack } = require('../utils/errorUtils');
const log = require('../utils/logger').child({ cat: 'ws-client,ws' });
const AsyncWebSocket = require('./AsyncWebSocket');
const actions = require('./actions/actions');
class Client {
/**
* @param {number} debugSynchronization
* @param {string} server
* @param {string} sessionId
*/
constructor({ debugSynchronization, server, sessionId }) {
this._onAppConnected = this._onAppConnected.bind(this);
this._onAppReady = this._onAppReady.bind(this);
this._onAppUnresponsive = this._onAppUnresponsive.bind(this);
this._onBeforeAppCrash = this._onBeforeAppCrash.bind(this);
this._onAppDisconnected = this._onAppDisconnected.bind(this);
this._onUnhandledServerError = this._onUnhandledServerError.bind(this);
this._logError = this._logError.bind(this);
this._sessionId = sessionId;
this._slowInvocationTimeout = debugSynchronization;
this._slowInvocationStatusHandle = null;
this._whenAppIsConnected = this._invalidState('before connecting to the app');
this._whenAppIsReady = this._whenAppIsConnected;
this._whenAppDisconnected = Deferred.resolved();
this._isCleaningUp = false;
this._pendingAppCrash = null;
this._appTerminationHandle = null;
this._successfulTestRun = true; // flag for cleanup
this._asyncWebSocket = new AsyncWebSocket(server);
this._serverUrl = server;
this.setEventCallback('appConnected', this._onAppConnected);
this.setEventCallback('ready', this._onAppReady);
this.setEventCallback('AppNonresponsiveDetected', this._onAppUnresponsive);
this.setEventCallback('AppWillTerminateWithError', this._onBeforeAppCrash);
this.setEventCallback('appDisconnected', this._onAppDisconnected);
this.setEventCallback('serverError', this._onUnhandledServerError);
}
/**
* Tells whether the DetoxManager (from native side) is connected to Detox server.
* In other words, if we can communicate with the app and send actions to it.
*
* @returns {boolean}
*/
get isConnected() {
return this._asyncWebSocket.isOpen && this._whenAppIsConnected.isResolved();
}
get serverUrl() {
return this._serverUrl;
}
async open() {
return this._asyncWebSocket.open();
}
async connect() {
await this.open();
const sessionStatus = await this.sendAction(new actions.Login(this._sessionId));
if (sessionStatus.appConnected) {
this._onAppConnected();
}
}
async cleanup() {
this._isCleaningUp = true;
this._unscheduleSlowInvocationQuery();
try {
if (this.isConnected) {
await this.sendAction(new actions.Cleanup(this._successfulTestRun)).catch(this._logError);
this._whenAppIsConnected = this._invalidState('while cleaning up');
this._whenAppIsReady = this._whenAppIsConnected;
}
} finally {
await this._asyncWebSocket.close().catch(this._logError);
}
delete this.terminateApp; // property injection
}
setEventCallback(event, callback) {
this._asyncWebSocket.setEventCallback(event, callback);
}
dumpPendingRequests({ testName } = {}) {
if (this._whenAppIsConnected.isPending()) {
const unreachableError = failedToReachTheApp.evenThoughAppWasLaunched();
log.error({ event: 'APP_UNREACHABLE' }, DetoxRuntimeError.format(unreachableError) + '\n\n');
}
if (this._asyncWebSocket.hasPendingActions()) {
const messages = _.values(this._asyncWebSocket.inFlightPromises).map(p => p.message);
let dump = 'The app has not responded to the network requests below:';
for (const msg of messages) {
dump += `\n (id = ${msg.messageId}) ${msg.type}: ${JSON.stringify(msg.params)}`;
}
const notice = testName
? `That might be the reason why the test "${testName}" has timed out.`
: `Unresponded network requests might result in timeout errors in Detox tests.`;
dump += `\n\n${notice}\n`;
log.warn({ event: 'PENDING_REQUESTS' }, dump);
}
this._asyncWebSocket.resetInFlightPromises();
}
async execute(invocation) {
if (typeof invocation === 'function') {
invocation = invocation();
}
try {
return await this.sendAction(new actions.Invoke(invocation));
} catch (err) {
this._successfulTestRun = false;
throw err;
}
}
async sendAction(action) {
if (this._pendingAppCrash) {
throw this._pendingAppCrash;
}
const { shouldQueryStatus, ...options } = this._inferSendOptions(action);
return await (shouldQueryStatus
? this._sendMonitoredAction(action, options)
: this._doSendAction(action, options));
}
_inferSendOptions(action) {
const timeout = action.timeout;
const shouldQueryStatus = timeout === 0;
return { shouldQueryStatus, timeout };
}
async _sendMonitoredAction(action, options) {
try {
this._scheduleSlowInvocationQuery();
return await this._doSendAction(action, options);
} finally {
this._unscheduleSlowInvocationQuery();
}
}
async _doSendAction(action, options) {
const errorWithUserStack = createErrorWithUserStack();
try {
const parsedResponse = await this._asyncWebSocket.send(action, options);
if (parsedResponse && parsedResponse.type === 'serverError') {
throw deserializeError(parsedResponse.params.error);
}
return await action.handle(parsedResponse);
} catch (err) {
throw replaceErrorStack(errorWithUserStack, asError(err));
}
}
async reloadReactNative() {
this._whenAppIsReady = new Deferred();
await this.sendAction(new actions.ReloadReactNative());
this._whenAppIsReady.resolve();
}
async waitUntilReady() {
if (!this._whenAppIsConnected.isResolved()) {
this._whenAppIsConnected = new Deferred();
this._whenAppIsReady = new Deferred();
await this._whenAppIsConnected.promise;
// TODO [2024-12-01]: optimize traffic (!) - we can just listen for 'ready' event
// if app always sends it upon load completion. On iOS it works,
// but not on Android. Afterwards, this will suffice:
//
// await this._whenAppIsReady.promise;
}
// TODO [2024-12-01]: move to else branch after the optimization ↑↑
if (!this._whenAppIsReady.isResolved()) {
this._whenAppIsReady = new Deferred();
await this.sendAction(new actions.Ready());
this._whenAppIsReady.resolve();
}
}
/** @async */
waitUntilDisconnected() {
return this._whenAppDisconnected.promise;
}
async waitForBackground() {
await this.sendAction(new actions.WaitForBackground());
}
async waitForActive() {
await this.sendAction(new actions.WaitForActive());
}
async captureViewHierarchy({ viewHierarchyURL }) {
return await this.sendAction(new actions.CaptureViewHierarchy({
viewHierarchyURL
}));
}
async generateViewHierarchyXml({ shouldInjectTestIds }) {
return await this.sendAction(new actions.GenerateViewHierarchyXml({
shouldInjectTestIds
}));
}
async currentStatus() {
return await this.sendAction(new actions.CurrentStatus());
}
async setSyncSettings(params) {
await this.sendAction(new actions.SetSyncSettings(params));
}
async shake() {
await this.sendAction(new actions.Shake());
}
async setOrientation(orientation) {
await this.sendAction(new actions.SetOrientation(orientation));
}
async startInstrumentsRecording({ recordingPath, samplingInterval }) {
await this.sendAction(new actions.SetInstrumentsRecordingState({
recordingPath, samplingInterval
}));
}
async stopInstrumentsRecording() {
await this.sendAction(new actions.SetInstrumentsRecordingState());
}
async deliverPayload(params) {
await this.sendAction(new actions.DeliverPayload(params));
}
async terminateApp() {
/* see the property injection from Detox.js */
}
_scheduleSlowInvocationQuery() {
if (this._slowInvocationTimeout > 0 && !this._isCleaningUp) {
this._slowInvocationStatusHandle = setTimeout(async () => {
let status;
try {
status = await this.currentStatus();
log.info({ event: 'APP_STATUS' }, status);
} catch (_e) {
log.debug({ event: 'APP_STATUS' }, 'Failed to execute the current status query.');
this._slowInvocationStatusHandle = null;
}
if (status) {
this._scheduleSlowInvocationQuery();
}
}, this._slowInvocationTimeout);
} else {
this._slowInvocationStatusHandle = null;
}
}
_unscheduleSlowInvocationQuery() {
if (this._slowInvocationStatusHandle) {
clearTimeout(this._slowInvocationStatusHandle);
this._slowInvocationStatusHandle = null;
}
}
_scheduleAppTermination() {
this._appTerminationHandle = setTimeout(async () => {
try {
await this.terminateApp();
} catch (e) {
log.error({ event: 'ERROR' }, DetoxRuntimeError.format(e));
}
}, 5000);
}
_unscheduleAppTermination() {
if (this._appTerminationHandle) {
clearTimeout(this._appTerminationHandle);
this._appTerminationHandle = null;
}
}
_onAppConnected() {
this._pendingAppCrash = null;
if (this._whenAppIsConnected.isPending()) {
this._whenAppIsConnected.resolve();
} else {
this._whenAppIsConnected = Deferred.resolved();
}
this._whenAppDisconnected = new Deferred();
}
_onAppReady() {
this._whenAppIsReady.resolve();
}
_onAppUnresponsive({ params }) {
const message = [
'Application nonresponsiveness detected!',
'On Android, this could imply an ANR alert, which evidently causes tests to fail.',
'Here\'s the native main-thread stacktrace from the device, to help you out (refer to device logs for the complete thread dump):',
params.threadDump,
'Refer to https://developer.android.com/training/articles/perf-anr for further details.'
].join('\n');
log.warn({ event: 'APP_NONRESPONSIVE' }, message);
}
_onBeforeAppCrash({ params }) {
if (this._pendingAppCrash) {
return;
}
this._pendingAppCrash = new DetoxRuntimeError({
message: 'The app has crashed, see the details below:',
debugInfo: params.errorDetails,
});
this._unscheduleSlowInvocationQuery();
this._whenAppIsConnected = this._invalidState('while the app is crashing');
this._whenAppIsReady = this._whenAppIsConnected;
this._scheduleAppTermination();
}
_onAppDisconnected() {
this._unscheduleSlowInvocationQuery();
this._unscheduleAppTermination();
this._whenAppIsConnected = this._invalidState('after the app has disconnected');
this._whenAppIsReady = this._whenAppIsConnected;
if (this._pendingAppCrash) {
this._whenAppDisconnected.reject(this._pendingAppCrash);
this._asyncWebSocket.rejectAll(this._pendingAppCrash);
} else if (this._asyncWebSocket.hasPendingActions()) {
const error = new DetoxRuntimeError('The app has unexpectedly disconnected from Detox server.');
this._asyncWebSocket.rejectAll(error);
this._whenAppDisconnected.resolve();
} else {
this._whenAppDisconnected.resolve();
}
}
_onUnhandledServerError(message) {
const { params } = message;
if (!params || !params.error) {
const err = new DetoxInternalError('Received an empty error message from Detox Server:\n' + util.inspect(message));
log.error({ event: 'ERROR' }, err.toString());
} else {
log.error({ event: 'ERROR' }, deserializeError(params.error).message);
}
}
_invalidState(state) {
return Deferred.rejected(
new DetoxInternalError(`Detected an attempt to interact with Detox Client ${state}.`)
);
}
_logError(e) {
log.error({ event: 'ERROR' }, DetoxRuntimeError.format(e));
}
}
module.exports = Client;