diff --git a/detox/ios/Detox.xcodeproj/project.pbxproj b/detox/ios/Detox.xcodeproj/project.pbxproj index b226bb0d7f..991469cba2 100644 --- a/detox/ios/Detox.xcodeproj/project.pbxproj +++ b/detox/ios/Detox.xcodeproj/project.pbxproj @@ -72,6 +72,7 @@ 39C3C3511DBF9A13008177E1 /* EarlGrey.framework in CopyFiles */ = {isa = PBXBuildFile; fileRef = 394767DC1DBF991E00D72256 /* EarlGrey.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 39C3C3531DBF9A19008177E1 /* SocketRocket.framework in CopyFiles */ = {isa = PBXBuildFile; fileRef = 394767E91DBF992400D72256 /* SocketRocket.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 39CEFCDB1E34E91B00A09124 /* DetoxUserNotificationDispatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39CEFCDA1E34E91B00A09124 /* DetoxUserNotificationDispatcher.swift */; }; + 39FFD9471FD730A600C97030 /* DetoxCrashHandler.mm in Sources */ = {isa = PBXBuildFile; fileRef = 39FFD9451FD730A600C97030 /* DetoxCrashHandler.mm */; }; 39F642281FDD5EB100468FED /* DTXLogging.h in Headers */ = {isa = PBXBuildFile; fileRef = 39F642201FDD5EB000468FED /* DTXLogging.h */; }; 39F642291FDD5EB100468FED /* DTXLogging.m in Sources */ = {isa = PBXBuildFile; fileRef = 39F642271FDD5EB000468FED /* DTXLogging.m */; }; 468731A51E6C6D0500F151BE /* EarlGrey+Detox.h in Headers */ = {isa = PBXBuildFile; fileRef = 468731A31E6C6D0500F151BE /* EarlGrey+Detox.h */; }; @@ -239,6 +240,7 @@ 39A34C6F1E30F10D00BEBB59 /* DetoxAppDelegateProxy.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DetoxAppDelegateProxy.h; sourceTree = ""; }; 39A34C701E30F10D00BEBB59 /* DetoxAppDelegateProxy.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DetoxAppDelegateProxy.m; sourceTree = ""; }; 39CEFCDA1E34E91B00A09124 /* DetoxUserNotificationDispatcher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DetoxUserNotificationDispatcher.swift; sourceTree = ""; }; + 39FFD9451FD730A600C97030 /* DetoxCrashHandler.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = DetoxCrashHandler.mm; sourceTree = ""; }; 39F642201FDD5EB000468FED /* DTXLogging.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = DTXLogging.h; path = DTXLoggingInfra/DTXLogging.h; sourceTree = SOURCE_ROOT; }; 39F642271FDD5EB000468FED /* DTXLogging.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = DTXLogging.m; path = DTXLoggingInfra/DTXLogging.m; sourceTree = SOURCE_ROOT; }; 39F6422A1FDD5EEC00468FED /* Detox.pch */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Detox.pch; sourceTree = ""; }; @@ -358,6 +360,7 @@ 3947679A1DBF985400D72256 /* Detox.h */, 394767A41DBF987E00D72256 /* DetoxManager.h */, 394767A51DBF987E00D72256 /* DetoxManager.m */, + 39FFD9451FD730A600C97030 /* DetoxCrashHandler.mm */, 39A34C6F1E30F10D00BEBB59 /* DetoxAppDelegateProxy.h */, 39A34C701E30F10D00BEBB59 /* DetoxAppDelegateProxy.m */, 39CEFCDA1E34E91B00A09124 /* DetoxUserNotificationDispatcher.swift */, @@ -712,6 +715,7 @@ 39CEFCDB1E34E91B00A09124 /* DetoxUserNotificationDispatcher.swift in Sources */, 394767C21DBF98A700D72256 /* GREYMatchers+Detox.m in Sources */, 394767AF1DBF987E00D72256 /* DetoxManager.m in Sources */, + 39FFD9471FD730A600C97030 /* DetoxCrashHandler.mm in Sources */, 46A6A63D1EF697BB00E3AA79 /* GREYConfiguration+Detox.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -924,6 +928,7 @@ OTHER_LDFLAGS = ( "-ObjC", "-all_load", + "-lstdc++", ); PRODUCT_BUNDLE_IDENTIFIER = com.wix.Detox; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -954,6 +959,7 @@ OTHER_LDFLAGS = ( "-ObjC", "-all_load", + "-lstdc++", ); PRODUCT_BUNDLE_IDENTIFIER = com.wix.Detox; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/detox/ios/Detox/DetoxCrashHandler.mm b/detox/ios/Detox/DetoxCrashHandler.mm new file mode 100644 index 0000000000..b771866af8 --- /dev/null +++ b/detox/ios/Detox/DetoxCrashHandler.mm @@ -0,0 +1,153 @@ +// +// DetoxCrashHandler.mm +// Detox +// +// Created by Leo Natan (Wix) on 12/5/17. +// Copyright © 2017 Wix. All rights reserved. +// + +#include +#import +#import +#import "DetoxManager.h" +#import + +#include +#include +#include +#include + +static void __DTXHandleCrash(NSException* exception, NSNumber* signal, NSString* other) +{ + NSNumber* threadNumber = [[NSThread currentThread] valueForKeyPath:@"private.seqNum"]; + NSString* queueName = @""; +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + dispatch_queue_t currentQueue = dispatch_get_current_queue(); +#pragma clang diagnostic pop + if(currentQueue) + { + queueName = [NSString stringWithUTF8String:dispatch_queue_get_label(currentQueue)]; + } + + NSMutableDictionary* report = [@{@"threadNumber": threadNumber, @"queueName": queueName} mutableCopy]; + if(exception) + { + report[@"errorDetails"] = exception.debugDescription; + } + else if(signal) + { + report[@"errorDetails"] = [NSString stringWithFormat:@"Signal %@ was raised\n%@", signal, [NSThread callStackSymbols]]; + } + else if(other) + { + report[@"errorDetails"] = other; + } + + [DetoxManager.sharedManager notifyOnCrashWithDetails:report]; + + [NSThread sleepForTimeInterval:5]; +} + +static NSSet* __supportedSignals; + +static int (*__orig_sigaction)(int, const struct sigaction * __restrict, struct sigaction * __restrict); +static int __dtx_sigaction(int signal, const struct sigaction * __restrict newaction, struct sigaction * __restrict oldaction) +{ + if([__supportedSignals containsObject:@(signal)] == NO) + { + return __orig_sigaction(signal, newaction, oldaction); + } + + return 0; +} + +static void __DTXHandleSignal(int signal) +{ + __DTXHandleCrash(nil, @(signal), nil); + + exit(1); +} + +OBJC_EXTERN std::type_info *__cxa_current_exception_type(void); +OBJC_EXTERN void __cxa_rethrow(void); + +static void (*__old_terminate)(void) = nil; +static void __dtx_terminate(void) +{ + std::type_info* exceptionType = __cxa_current_exception_type(); + if (exceptionType == nullptr) + { + // No current exception. + __DTXHandleCrash(nil, nil, @"Unknown error"); + (*__old_terminate)(); + } + else + { + // There is a current exception. Check if it's an objc exception. + @try + { + __cxa_rethrow(); + } + @catch (id e) + { + __DTXHandleCrash(e, nil, nil); + // It's an objc object. Call Foundation's handler, if any. + void (*handler)(NSException*) = NSGetUncaughtExceptionHandler(); + if(handler != nullptr) + { + handler(e); + } + } + @catch (...) + { + const char* exceptionTypeMangledName = exceptionType->name(); + + int status = -1; + const char* demangled = abi::__cxa_demangle(exceptionTypeMangledName, NULL, NULL, &status); + NSString* exceptionTypeName = nil; + if(demangled) + { + exceptionTypeName = [NSString stringWithUTF8String:demangled]; + free((void*)demangled); + } + else + { + exceptionTypeName = [NSString stringWithUTF8String:exceptionTypeMangledName]; + } + + __DTXHandleCrash(nil, nil, [NSString stringWithFormat:@"C++ exception of type \"%@\" was thrown", exceptionTypeName]); + // It's not an objc object. Continue to C++ terminate. + (*__old_terminate)(); + } + } +} + +__attribute__((constructor)) +static void __DTXInstallCrashHandlers() +{ + __old_terminate = std::set_terminate(__dtx_terminate); + + __supportedSignals = [NSSet setWithArray:@[@(SIGQUIT), @(SIGILL), @(SIGTRAP), @(SIGABRT), @(SIGFPE), @(SIGBUS), @(SIGSEGV), @(SIGSYS)]]; + + __orig_sigaction = (int (*)(int, const struct sigaction * __restrict, struct sigaction * __restrict))dlsym(RTLD_DEFAULT, "sigaction"); + + { + struct rebinding rebindings[] = { + {"sigaction", (void*)__dtx_sigaction, nullptr} + }; + + rebind_symbols(rebindings, 1); + } + + struct sigaction signalAction; + memset(&signalAction, 0, sizeof(signalAction)); + sigemptyset(&signalAction.sa_mask); + signalAction.sa_handler = &__DTXHandleSignal; + + [__supportedSignals enumerateObjectsUsingBlock:^(NSNumber * _Nonnull obj, BOOL * _Nonnull stop) { + int signum = obj.intValue; + + __orig_sigaction(signum, &signalAction, nullptr); + }]; +} diff --git a/detox/ios/Detox/DetoxManager.h b/detox/ios/Detox/DetoxManager.h index 7de5890a8e..4388eb651e 100644 --- a/detox/ios/Detox/DetoxManager.h +++ b/detox/ios/Detox/DetoxManager.h @@ -7,13 +7,12 @@ // #import -#import "WebSocket.h" -#import "TestRunner.h" -#import "ReactNativeSupport.h" -@interface DetoxManager : NSObject +@interface DetoxManager : NSObject -+ (instancetype)sharedInstance; ++ (instancetype)sharedManager; - (void)connectToServer:(NSString*)url withSessionId:(NSString*)sessionId; +- (void)notifyOnCrashWithDetails:(NSDictionary*)details; + @end diff --git a/detox/ios/Detox/DetoxManager.m b/detox/ios/Detox/DetoxManager.m index 981264bf39..f14bf858b7 100644 --- a/detox/ios/Detox/DetoxManager.m +++ b/detox/ios/Detox/DetoxManager.m @@ -7,6 +7,11 @@ // #import "DetoxManager.h" + +#import "WebSocket.h" +#import "TestRunner.h" +#import "ReactNativeSupport.h" + #import #import "DetoxAppDelegateProxy.h" #import "EarlGreyExtensions.h" @@ -14,11 +19,11 @@ DTX_CREATE_LOG(DetoxManager) -@interface DetoxManager() +@interface DetoxManager() @property (nonatomic) BOOL isReady; -@property (nonatomic, retain) WebSocket *websocket; -@property (nonatomic, retain) TestRunner *testRunner; +@property (nonatomic, strong) WebSocket *webSocket; +@property (nonatomic, strong) TestRunner *testRunner; @end @@ -41,14 +46,13 @@ static void detoxConditionalInit() // if these args were not provided as part of options, don't start Detox at all! return; } - - [[DetoxManager sharedInstance] connectToServer:detoxServer withSessionId:detoxSessionId]; + + [[DetoxManager sharedManager] connectToServer:detoxServer withSessionId:detoxSessionId]; } - @implementation DetoxManager -+ (instancetype)sharedInstance ++ (instancetype)sharedManager { static DetoxManager *sharedInstance = nil; static dispatch_once_t onceToken; @@ -63,8 +67,8 @@ - (instancetype)init self = [super init]; if (self == nil) return nil; - self.websocket = [[WebSocket alloc] init]; - self.websocket.delegate = self; + self.webSocket = [[WebSocket alloc] init]; + self.webSocket.delegate = self; self.testRunner = [[TestRunner alloc] init]; self.testRunner.delegate = self; @@ -78,7 +82,7 @@ - (instancetype)init - (void)connectToServer:(NSString*)url withSessionId:(NSString*)sessionId { - [self.websocket connectToServer:url withSessionId:sessionId]; + [self.webSocket connectToServer:url withSessionId:sessionId]; } - (void)websocketDidConnect @@ -86,7 +90,7 @@ - (void)websocketDidConnect if (![ReactNativeSupport isReactNativeApp]) { _isReady = YES; - [self.websocket sendAction:@"ready" withParams:@{} withMessageId: @-1000]; + [self.webSocket sendAction:@"ready" withParams:@{} withMessageId:@-1000]; } } @@ -96,21 +100,21 @@ - (void)websocketDidReceiveAction:(NSString *)type withParams:(NSDictionary *)pa if([type isEqualToString:@"invoke"]) { - [self.testRunner invoke:params withMessageId: messageId]; + [self.testRunner invoke:params withMessageId:messageId]; return; } else if([type isEqualToString:@"isReady"]) { if(_isReady) { - [self.websocket sendAction:@"ready" withParams:@{} withMessageId: @-1000]; + [self.webSocket sendAction:@"ready" withParams:@{} withMessageId:@-1000]; } return; } else if([type isEqualToString:@"cleanup"]) { [self.testRunner cleanup]; - [self.websocket sendAction:@"cleanupDone" withParams:@{} withMessageId: messageId]; + [self.webSocket sendAction:@"cleanupDone" withParams:@{} withMessageId:messageId]; return; } else if([type isEqualToString:@"userNotification"]) @@ -119,7 +123,7 @@ - (void)websocketDidReceiveAction:(NSString *)type withParams:(NSDictionary *)pa NSURL* userNotificationDataURL = [NSURL fileURLWithPath:params[@"detoxUserNotificationDataURL"]]; DetoxUserNotificationDispatcher* dispatcher = [[DetoxUserNotificationDispatcher alloc] initWithUserNotificationDataURL:userNotificationDataURL]; [dispatcher dispatchOnAppDelegate:DetoxAppDelegateProxy.currentAppDelegateProxy simulateDuringLaunch:NO]; - [self.websocket sendAction:@"userNotificationDone" withParams:@{} withMessageId: messageId]; + [self.webSocket sendAction:@"userNotificationDone" withParams:@{} withMessageId: messageId]; }]; } else if([type isEqualToString:@"openURL"]) @@ -142,13 +146,11 @@ - (void)websocketDidReceiveAction:(NSString *)type withParams:(NSDictionary *)pa [[UIApplication sharedApplication].delegate application:[UIApplication sharedApplication] openURL:URLToOpen options:options]; } - [self.websocket sendAction:@"openURLDone" withParams:@{} withMessageId: messageId]; + [self.webSocket sendAction:@"openURLDone" withParams:@{} withMessageId: messageId]; }]; } else if([type isEqualToString:@"shakeDevice"]) - { - - } + { } else if([type isEqualToString:@"reactNativeReload"]) { _isReady = NO; @@ -165,7 +167,7 @@ - (void)websocketDidReceiveAction:(NSString *)type withParams:(NSDictionary *)pa NSMutableDictionary* statsStatus = [[[EarlGreyStatistics sharedInstance] currentStatus] mutableCopy]; statsStatus[@"messageId"] = messageId; - [self.websocket sendAction:@"currentStatusResult" withParams:statsStatus withMessageId: messageId]; + [self.webSocket sendAction:@"currentStatusResult" withParams:statsStatus withMessageId:messageId]; } } @@ -174,7 +176,7 @@ - (void)_waitForRNLoadWithId:(id)messageId __weak __typeof(self) weakSelf = self; [ReactNativeSupport waitForReactNativeLoadWithCompletionHandler:^{ weakSelf.isReady = YES; - [weakSelf.websocket sendAction:@"ready" withParams:@{} withMessageId: @-1000]; + [weakSelf.webSocket sendAction:@"ready" withParams:@{} withMessageId:@-1000]; }]; } @@ -185,19 +187,24 @@ - (void)testRunnerOnInvokeResult:(id)res withMessageId:(NSNumber *)messageId { res = [NSString stringWithFormat:@"(%@)", NSStringFromClass([res class])]; } - [self.websocket sendAction:@"invokeResult" withParams:@{@"result": res} withMessageId: messageId]; + [self.webSocket sendAction:@"invokeResult" withParams:@{@"result": res} withMessageId:messageId]; } - (void)testRunnerOnTestFailed:(NSString *)details withMessageId:(NSNumber *) messageId { if (details == nil) details = @""; - [self.websocket sendAction:@"testFailed" withParams:@{@"details": details} withMessageId: messageId]; + [self.webSocket sendAction:@"testFailed" withParams:@{@"details": details} withMessageId:messageId]; } - (void)testRunnerOnError:(NSString *)error withMessageId:(NSNumber *) messageId { if (error == nil) error = @""; - [self.websocket sendAction:@"error" withParams:@{@"error": error} withMessageId: messageId]; + [self.webSocket sendAction:@"error" withParams:@{@"error": error} withMessageId:messageId]; +} + +- (void)notifyOnCrashWithDetails:(NSDictionary*)details +{ + [self.webSocket sendAction:@"AppWillTerminateWithError" withParams:details withMessageId:@-10000]; } @end diff --git a/detox/ios/Detox/TestRunner.m b/detox/ios/Detox/TestRunner.m index 5f6126c43b..8dd21e1a48 100644 --- a/detox/ios/Detox/TestRunner.m +++ b/detox/ios/Detox/TestRunner.m @@ -61,7 +61,7 @@ - (void)onTestFailed:(NSString *)details { } } -- (void)invoke:(NSDictionary*)params withMessageId: (NSNumber *)messageId +- (void)invoke:(NSDictionary*)params withMessageId:(NSNumber *)messageId { self.currentMessageId = messageId; grey_execute_async(^{ diff --git a/detox/src/Detox.js b/detox/src/Detox.js index 14a02ad50b..56702524b5 100644 --- a/detox/src/Detox.js +++ b/detox/src/Detox.js @@ -92,14 +92,25 @@ class Detox { const testArtifactsPath = this._artifactsPathsProvider.createPathForTest(this._currentTestNumber, ...testNameComponents); this.device.setArtifactsDestination(testArtifactsPath); } + + await this._handleAppCrash(testNameComponents[1]); } async afterEach(suiteName, testName) { if(this._artifactsPathsProvider !== undefined) { await this.device.finalizeArtifacts(); } + + await this._handleAppCrash(testName); } + async _handleAppCrash(testName) { + const pendingAppCrash = this.client.getPendingCrashAndReset(); + if (pendingAppCrash) { + log.error('',`App crashed in test '${testName}', here's the native stack trace: \n${pendingAppCrash}`); + await this.device.launchApp({newInstance:true}); + } + } async _getSessionConfig() { const session = this.userSession || await configuration.defaultSession(); diff --git a/detox/src/Detox.test.js b/detox/src/Detox.test.js index fd4080b996..5fdeaa4311 100644 --- a/detox/src/Detox.test.js +++ b/detox/src/Detox.test.js @@ -169,4 +169,13 @@ describe('Detox', () => { await detox.init({initGlobals: false}); expect(global.device).not.toBeDefined(); }); + + it(`handleAppCrash if client has a pending crash`, async () => { + Detox = require('./Detox'); + detox = new Detox({deviceConfig: validDeviceConfigWithSession}); + await detox.init(); + detox.client.getPendingCrashAndReset.mockReturnValueOnce('crash'); + await detox.afterEach(); + expect(device.launchApp).toHaveBeenCalledTimes(1); + }); }); diff --git a/detox/src/client/AsyncWebSocket.js b/detox/src/client/AsyncWebSocket.js index b897268a69..e944c35e12 100644 --- a/detox/src/client/AsyncWebSocket.js +++ b/detox/src/client/AsyncWebSocket.js @@ -8,6 +8,7 @@ class AsyncWebSocket { this.url = url; this.ws = undefined; this.inFlightPromises = {}; + this.eventCallbacks = {}; this.messageIdCounter = 0; } @@ -32,11 +33,15 @@ class AsyncWebSocket { this.ws.onmessage = (response) => { log.verbose(`ws`, `onMessage: ${response.data}`); - let pendingId = JSON.parse(response.data).messageId; - let pendingPromise = this.inFlightPromises[pendingId]; + let messageId = JSON.parse(response.data).messageId; + let pendingPromise = this.inFlightPromises[messageId]; if (pendingPromise) { pendingPromise.resolve(response.data); - delete this.inFlightPromises[pendingId]; + delete this.inFlightPromises[messageId]; + } + let eventCallback = this.eventCallbacks[messageId]; + if (eventCallback) { + eventCallback(response.data); } }; @@ -58,6 +63,10 @@ class AsyncWebSocket { }); } + setEventCallback(eventId, callback) { + this.eventCallbacks[eventId] = callback; + } + async close() { return new Promise(async(resolve, reject) => { if (this.ws) { @@ -83,6 +92,16 @@ class AsyncWebSocket { } return this.ws.readyState === WebSocket.OPEN; } + + rejectAll(error) { + _.forEach(this.inFlightPromises, (promise, messageId) => { + let pendingPromise = this.inFlightPromises[messageId]; + pendingPromise.reject(error); + delete this.inFlightPromises[messageId]; + }); + + + } } module.exports = AsyncWebSocket; diff --git a/detox/src/client/AsyncWebSocket.test.js b/detox/src/client/AsyncWebSocket.test.js index aaa42f31b6..eecb58ebfe 100644 --- a/detox/src/client/AsyncWebSocket.test.js +++ b/detox/src/client/AsyncWebSocket.test.js @@ -165,6 +165,27 @@ describe('AsyncWebSocket', () => { } }); + it(`eventCallback should be triggered on a registered messageId when sent from testee`, async () => { + const mockCallback = jest.fn(); + const mockedResponse = generateResponse('onmessage', -10000); + await connect(client); + client.setEventCallback(-10000, mockCallback); + + client.ws.onmessage(mockedResponse); + expect(mockCallback).toHaveBeenCalledWith(mockedResponse.data); + }); + + it(`rejectAll should throw error to all pending promises`, async () => { + const error = new Error('error'); + await connect(client); + const message1 = client.send(generateRequest()); + const message2 = client.send(generateRequest()); + + client.rejectAll(error); + await expect(message1).rejects.toEqual(error); + await expect(message2).rejects.toEqual(error); + }); + async function connect(client) { const result = {}; const promise = client.open(); diff --git a/detox/src/client/Client.js b/detox/src/client/Client.js index 38a0430cba..6fab29b8d6 100644 --- a/detox/src/client/Client.js +++ b/detox/src/client/Client.js @@ -10,6 +10,12 @@ class Client { this.slowInvocationStatusHandler = null; this.slowInvocationTimeout = argparse.getArgValue('debug-synchronization'); this.successfulTestRun = true; // flag for cleanup + this.pandingAppCrash; + + this.setActionListener(new actions.AppWillTerminateWithError(), (response) => { + this.pandingAppCrash = response.params.errorDetails; + this.ws.rejectAll(this.pandingAppCrash); + }); } async connect() { @@ -18,7 +24,7 @@ class Client { } async reloadReactNative() { - await this.sendAction(new actions.ReloadReactNative(), -1000); + await this.sendAction(new actions.ReloadReactNative()); } async sendUserNotification(params) { @@ -26,14 +32,13 @@ class Client { } async waitUntilReady() { - await this.sendAction(new actions.Ready(), -1000); + await this.sendAction(new actions.Ready()); this.isConnected = true; } async cleanup() { clearTimeout(this.slowInvocationStatusHandler); - - if (this.isConnected) { + if (this.isConnected && !this.pandingAppCrash) { await this.sendAction(new actions.Cleanup(this.successfulTestRun)); this.isConnected = false; } @@ -68,8 +73,27 @@ class Client { clearTimeout(this.slowInvocationStatusHandler); } - async sendAction(action, messageId) { - const response = await this.ws.send(action, messageId); + getPendingCrashAndReset() { + const crash = this.pandingAppCrash; + this.pandingAppCrash = undefined; + + return crash; + } + + setActionListener(action, clientCallback) { + this.ws.setEventCallback(action.messageId, (response) => { + const parsedResponse = JSON.parse(response); + action.handle(parsedResponse); + + /* istanbul ignore next */ + if (clientCallback) { + clientCallback(parsedResponse); + } + }); + } + + async sendAction(action) { + const response = await this.ws.send(action, action.messageId); const parsedResponse = JSON.parse(response); await action.handle(parsedResponse); return parsedResponse; diff --git a/detox/src/client/Client.test.js b/detox/src/client/Client.test.js index 74635fd32e..db0f5ca8c4 100644 --- a/detox/src/client/Client.test.js +++ b/detox/src/client/Client.test.js @@ -150,6 +150,25 @@ describe('Client', () => { } }); + it(`save a pending error if AppWillTerminateWithError event is sent to tester`, async () => { + client.ws.setEventCallback = jest.fn(); + await connect(); + + triggerAppWillTerminateWithError(); + + expect(client.getPendingCrashAndReset()).toBeDefined(); + + function triggerAppWillTerminateWithError() { + const event = JSON.stringify({ + type: "AppWillTerminateWithError", + params: {errorDetails: "someDetails"}, + messageId: -10000 + }); + + client.ws.setEventCallback.mock.calls[0][1](event); + } + }); + async function connect() { client = new Client(config); client.ws.send.mockReturnValueOnce(response("loginSuccess", {}, 1)); diff --git a/detox/src/client/DetoxError.js b/detox/src/client/DetoxError.js new file mode 100644 index 0000000000..6cbb668c9f --- /dev/null +++ b/detox/src/client/DetoxError.js @@ -0,0 +1,11 @@ +class DetoxError extends Error { + constructor(message) { + super(message); + Error.stackTraceLimit = 0; + + Error.captureStackTrace(this, this.constructor); + this.name = this.constructor.name; + } +} + +module.exports = DetoxError; diff --git a/detox/src/client/actions/actions.js b/detox/src/client/actions/actions.js index f659a702f9..311f3e51c2 100644 --- a/detox/src/client/actions/actions.js +++ b/detox/src/client/actions/actions.js @@ -32,6 +32,7 @@ class Login extends Action { class Ready extends Action { constructor() { super('isReady'); + this.messageId = -1000; } async handle(response) { @@ -42,6 +43,7 @@ class Ready extends Action { class ReloadReactNative extends Action { constructor() { super('reactNativeReload'); + this.messageId = -1000; } async handle(response) { @@ -117,6 +119,18 @@ class CurrentStatus extends Action { } } +class AppWillTerminateWithError extends Action { + constructor(params) { + super(params); + this.messageId = -10000; + } + + handle(response) { + this.expectResponseOfType(response, 'AppWillTerminateWithError'); + return response.params.errorDetails; + } +} + module.exports = { Login, Ready, @@ -125,5 +139,6 @@ module.exports = { Cleanup, openURL, SendUserNotification, - CurrentStatus + CurrentStatus, + AppWillTerminateWithError }; diff --git a/detox/test/e2e/c-actions.js b/detox/test/e2e/c-actions.js index 9916529b31..64af105383 100644 --- a/detox/test/e2e/c-actions.js +++ b/detox/test/e2e/c-actions.js @@ -81,7 +81,7 @@ describe('Actions', () => { await expect(element(by.text('PullToReload Working!!!'))).toBeVisible(); }); - it.only('should not wait for long timeout (>1.5s)', async () => { + it('should not wait for long timeout (>1.5s)', async () => { await element(by.id('WhyDoAllTheTestIDsHaveTheseStrangeNames')).tap(); await expect(element(by.id('WhyDoAllTheTestIDsHaveTheseStrangeNames'))).toBeVisible(); }); diff --git a/detox/test/e2e/init.js b/detox/test/e2e/init.js index 0c3021b488..b122f47fb4 100644 --- a/detox/test/e2e/init.js +++ b/detox/test/e2e/init.js @@ -14,5 +14,5 @@ beforeEach(async function() { }); afterEach(async function() { - await detox.afterEach(); + await detox.afterEach(this.currentTest.parent.title, this.currentTest.title); }); \ No newline at end of file diff --git a/detox/test/e2e/p-crash-handling.js b/detox/test/e2e/p-crash-handling.js new file mode 100644 index 0000000000..89a8d7be53 --- /dev/null +++ b/detox/test/e2e/p-crash-handling.js @@ -0,0 +1,21 @@ +describe(':ios: Crash Handling', () => { + + it('Should throw error upon app crash', async () => { + await device.reloadReactNative(); + let failed = false; + + try { + await element(by.text('Crash')).tap(); + await element(by.text('Crash')).tap(); + } catch (ex) { + failed = true; + } + + if (!failed) throw new Error('Test should have thrown an error, but did not'); + }); + + it('Should recover from app crash', async () => { + await device.launchApp({newInstance: false}); + await expect(element(by.text('Sanity'))).toBeVisible(); + }); +}); \ No newline at end of file diff --git a/detox/test/src/app.js b/detox/test/src/app.js index fa000aca38..1106b3422f 100644 --- a/detox/test/src/app.js +++ b/detox/test/src/app.js @@ -19,16 +19,22 @@ class example extends Component { }; } - renderScreenButton(title, component) { + renderButton(title, onPressCallback) { return ( { - this.setState({screen: component}); + onPressCallback(); }}> {title} ); } + renderScreenButton(title, component) { + return this.renderButton(title, () => { + this.setState({screen: component}); + }); + } + renderText(text) { return ( @@ -78,6 +84,7 @@ class example extends Component { {this.renderScreenButton('Network', Screens.NetworkScreen)} {this.renderScreenButton('Animations', Screens.AnimationsScreen)} {this.renderScreenButton('Location', Screens.LocationScreen)} + {this.renderButton('Crash', () => {throw new Error('Simulated Crash')})} ); }