Repository: wix/Detox
Pull request #2427 (2020-10-19 13:29:50)

Improve content of native errors shown on the JS test runner
- [ ] This is a small change 
- [ ] This change has been discussed in issue #<?> and the solution has been agreed upon with maintainers.

---

**Description:**
Often enough, errors sent from the device's native fail to convey proper meaning to test user. Usually (perhaps always), this happens because the test runner side (JS) only logs the top-most wrapping error, and not the very original one, specified by the error's `cause` field (recursively).

This attempts at fixing that by drilling down to the root cause of errors, before sending them to the test-runner side of Detox. As an example of why this works, it is verified in the recently added slow-tap e2e test in the actions suite.


Patch:
diff --git a/detox/android/detox/src/main/java/com/wix/detox/DetoxActionHandlers.kt b/detox/android/detox/src/main/java/com/wix/detox/DetoxActionHandlers.kt
index 67f870713..e50dd5247 100644
--- a/detox/android/detox/src/main/java/com/wix/detox/DetoxActionHandlers.kt
+++ b/detox/android/detox/src/main/java/com/wix/detox/DetoxActionHandlers.kt
@@ -3,6 +3,7 @@ package com.wix.detox
 import android.content.Context
 import android.util.Log
 import androidx.test.espresso.IdlingResource
+import com.wix.detox.common.extractRootCause
 import com.wix.detox.instruments.DetoxInstrumentsException
 import com.wix.detox.instruments.DetoxInstrumentsManager
 import com.wix.invoke.MethodInvocation
@@ -47,22 +48,33 @@ class InvokeActionHandler(
         private val errorParse: (e: Throwable?) -> String)
     : DetoxActionHandler {
 
+    private val VIEW_HIERARCHY_TEXT = "View Hierarchy:"
+
     override fun handle(params: String, messageId: Long) {
         try {
             val invocationResult = methodInvocation.invoke(params)
             wsClient.sendAction("invokeResult", mapOf<String, Any?>("result" to invocationResult), messageId)
         } catch (e: InvocationTargetException) {
             Log.i(LOG_TAG, "Test exception", e)
-            val message = e.targetException.message
-            val error = message?.substringBefore("View Hierarchy:")?.trim()
-            val viewHierarchy = message?.substringAfter("View Hierarchy:")
-            wsClient.sendAction("testFailed", mapOf<String, Any?>("details" to "${error}\n",
-                                                                        "viewHierarchy" to viewHierarchy), messageId)
+            val payload = extractFailurePayload(e)
+            wsClient.sendAction("testFailed", payload, messageId)
         }  catch (e: Exception) {
             Log.e(LOG_TAG, "Exception", e)
             wsClient.sendAction("error", mapOf<String, Any?>("error" to "${errorParse(e)}\nCheck device logs for full details!\n"), messageId)
         }
     }
+
+    private fun extractFailurePayload(e: InvocationTargetException): Map<String, Any?>
+        = e.targetException.message?.let { message: String ->
+            if (message.contains(VIEW_HIERARCHY_TEXT)) {
+                val error = message.substringBefore(VIEW_HIERARCHY_TEXT).trim()
+                val viewHierarchy = message.substringAfter(VIEW_HIERARCHY_TEXT).trim()
+                mapOf<String, Any?>("details" to "${error}\n", "viewHierarchy" to viewHierarchy)
+            } else {
+                val error = extractRootCause(e.targetException)
+                mapOf<String, Any?>("details" to error.message)
+            }
+        } ?: emptyMap()
 }
 
 class CleanupActionHandler(
diff --git a/detox/android/detox/src/main/java/com/wix/detox/common/ErrorUtils.kt b/detox/android/detox/src/main/java/com/wix/detox/common/ErrorUtils.kt
new file mode 100644
index 000000000..d4a06cec5
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/common/ErrorUtils.kt
@@ -0,0 +1,9 @@
+package com.wix.detox.common
+
+fun extractRootCause(t: Throwable): Throwable {
+    var ex: Throwable = t
+    while (ex.cause != null) {
+        ex = ex.cause!!
+    }
+    return ex
+}
diff --git a/detox/src/client/actions/actions.js b/detox/src/client/actions/actions.js
index 3b03604c9..348bcda32 100644
--- a/detox/src/client/actions/actions.js
+++ b/detox/src/client/actions/actions.js
@@ -114,11 +114,15 @@ class Invoke extends Action {
   async handle(response) {
     switch (response.type) {
       case 'testFailed':
-        throw new Error('Test Failed: ' + response.params.details +
+        let message = 'Test Failed: ' + response.params.details;
+        if (response.params.viewHierarchy) {
           /* istanbul ignore next */
-          (log.level() <= bunyan.DEBUG ?
-          '\nView Hierarchy:\n' + response.params.viewHierarchy :
-          '\nTIP: To print view hierarchy on failed actions/matches, use loglevel verbose and above.'));
+          message += (log.level() <= bunyan.DEBUG ?
+              '\nView Hierarchy:\n' + response.params.viewHierarchy :
+              '\nTIP: To print view hierarchy on failed actions/matches, use log-level verbose or higher.');
+        }
+
+        throw new Error(message);
       case 'invokeResult':
         return response.params;
       case 'error':
diff --git a/detox/src/utils/__mocks__/logger.js b/detox/src/utils/__mocks__/logger.js
index 2555a5670..dfeb19ba5 100644
--- a/detox/src/utils/__mocks__/logger.js
+++ b/detox/src/utils/__mocks__/logger.js
@@ -7,6 +7,7 @@ class FakeLogger {
     this.opts = opts;
     this.log = jest.fn();
     this.reinitialize = jest.fn();
+    this.level = jest.fn();
 
     for (const method of METHODS) {
       this[method] = jest.fn().mockImplementation((...args) => {


Test patch:
diff --git a/detox/android/detox/src/test/java/com/wix/detox/DetoxActionHandlersSpec.kt b/detox/android/detox/src/test/java/com/wix/detox/DetoxActionHandlersSpec.kt
index 30feab4dc..3b2635f77 100644
--- a/detox/android/detox/src/test/java/com/wix/detox/DetoxActionHandlersSpec.kt
+++ b/detox/android/detox/src/test/java/com/wix/detox/DetoxActionHandlersSpec.kt
@@ -138,8 +138,8 @@ object DetoxActionHandlersSpec : Spek({
                         eq(messageId))
             }
 
-            it("should handle an InvocationTargetException") {
-                val targetException = Exception("mock-error-reason")
+            it("should handle an InvocationTargetException and extract view hierarchy") {
+                val targetException = Exception("before View Hierarchy: after")
                 val exception = InvocationTargetException(targetException)
                 whenever(methodInvocationMock.invoke(isA<String>())).thenThrow(exception)
 
@@ -147,10 +147,31 @@ object DetoxActionHandlersSpec : Spek({
 
                 verify(wsClient).sendAction(
                         eq("testFailed"),
-                        argThat { size == 2 && this["details"] is String &&
-                                               this["viewHierarchy"] is String },
+                        argThat {
+                            this["details"] == "before\n" &&
+                            this["viewHierarchy"] == "after" &&
+                            size == 2
+                        },
                         eq(messageId))
             }
+
+            it("should handle a non-view-hierarchy InvocationTargetException") {
+                val rootException = RuntimeException("root-exception-mock")
+                val targetException = Exception("target-exception-mock", rootException)
+                val exception = InvocationTargetException(targetException)
+                whenever(methodInvocationMock.invoke(isA<String>())).thenThrow(exception)
+
+                uut().handle(params, messageId)
+
+                verify(wsClient).sendAction(
+                        eq("testFailed"),
+                        argThat {
+                            this["details"] == "root-exception-mock" &&
+                            size == 1
+                        },
+                        eq(messageId))
+
+            }
         }
 
         describe("InstrumentsRecording recording state actions") {
diff --git a/detox/src/client/Client.test.js b/detox/src/client/Client.test.js
index 71cccef9d..7bf32c681 100644
--- a/detox/src/client/Client.test.js
+++ b/detox/src/client/Client.test.js
@@ -9,6 +9,7 @@ describe('Client', () => {
   let WebSocket;
   let Client;
   let client;
+  let bunyan;
   let log;
 
   beforeEach(() => {
@@ -18,8 +19,12 @@ describe('Client', () => {
     jest.mock('../utils/argparse');
     argparse = require('../utils/argparse');
 
+    jest.mock('bunyan')
+    bunyan = require('bunyan');
+
     Client = require('./Client');
     log = require('../utils/logger');
+    log.level.mockReturnValue(bunyan.DEBUG)
   });
 
   it(`reloadReactNative() - should receive ready from device and resolve`, async () => {
@@ -279,14 +284,41 @@ describe('Client', () => {
     expect(client.ws.send).toHaveBeenCalledTimes(2);
   });
 
-  it(`execute() - "testFailed" result should throw`, async () => {
+  it(`execute() - "testFailed" result should throw with view hierarchy`, async () => {
+    await connect();
+    client.ws.send.mockReturnValueOnce(response("testFailed",  {details: "this is an error", viewHierarchy: 'mock-hierarchy'}, 1));
+    const call = invoke.call(invoke.IOS.Class('GREYMatchers'), 'matcherForAccessibilityLabel:', 'test');
+    try {
+      await client.execute(call);
+    } catch (ex) {
+      expect(ex).toBeDefined();
+      expect(ex.toString()).toContain('View Hierarchy:\nmock-hierarchy');
+    }
+  });
+
+  it(`execute() - "testFailed" result should throw with view-hierarchy hint`, async () => {
+    log.level.mockReturnValue(bunyan.INFO);
+
+    await connect();
+    client.ws.send.mockReturnValueOnce(response("testFailed",  {details: "this is an error", viewHierarchy: 'mock-hierarchy'}, 1));
+    const call = invoke.call(invoke.IOS.Class('GREYMatchers'), 'matcherForAccessibilityLabel:', 'test');
+    try {
+      await client.execute(call);
+    } catch (ex) {
+      expect(ex).toBeDefined();
+      expect(ex.toString()).toContain('use log-level verbose or higher');
+    }
+  });
+
+  it(`execute() - "testFailed" result should throw without a view hierarchy`, async () => {
     await connect();
-    client.ws.send.mockReturnValueOnce(response("testFailed",  {details: "this is an error"}, 1));
+    client.ws.send.mockReturnValueOnce(response("testFailed",  {details: "this is an error", viewHierarchy: undefined}, 1));
     const call = invoke.call(invoke.IOS.Class('GREYMatchers'), 'matcherForAccessibilityLabel:', 'test');
     try {
       await client.execute(call);
     } catch (ex) {
       expect(ex).toBeDefined();
+      expect(ex.toString()).not.toContain('View Hierarchy:');
     }
   });
 
diff --git a/detox/test/e2e/03.actions.test.js b/detox/test/e2e/03.actions.test.js
index 8e2049dc8..973b67544 100644
--- a/detox/test/e2e/03.actions.test.js
+++ b/detox/test/e2e/03.actions.test.js
@@ -72,6 +72,9 @@ describe('Actions', () => {
       await driver.sluggishTapElement.tap();
     } catch (e) {
       console.log('Got an expected error', e);
+      if (!e.toString().includes('Tap handled too slowly, and turned into a long-tap!')) {
+        throw new Error('Error content isn\'t as expected!');
+      }
       return;
     }