Repository: wix/Detox
Pull request #3718 (2022-11-20 13:05:55)

feat(android): Detect busy background threads
## Description
Espresso considers the device not idle if it has, among other things, active asynctasks, however we were not able to see these background tasks and as a result would print messages of "Device seems to be idle", instead of actually indicating the issue. In this PR, the ability to check the status of asynctasks has been added in order to provide more accurate sync debugging. 

- This pull request is associated with #3617



Patch:
diff --git a/detox/android/detox/src/full/java/com/wix/detox/adapters/server/QueryStatusActionHandler.kt b/detox/android/detox/src/full/java/com/wix/detox/adapters/server/QueryStatusActionHandler.kt
index 2e942e1bb..971bd9c26 100644
--- a/detox/android/detox/src/full/java/com/wix/detox/adapters/server/QueryStatusActionHandler.kt
+++ b/detox/android/detox/src/full/java/com/wix/detox/adapters/server/QueryStatusActionHandler.kt
@@ -1,96 +1,28 @@
 package com.wix.detox.adapters.server
 
-import androidx.test.espresso.IdlingResource
 import com.wix.detox.TestEngineFacade
-import com.wix.detox.reactnative.idlingresources.DescriptiveIdlingResource
+import com.wix.detox.inquiry.DetoxBusyResource
 
 class QueryStatusActionHandler(
     private val outboundServerAdapter: OutboundServerAdapter,
     private val testEngineFacade: TestEngineFacade
-)
-    : DetoxActionHandler {
+) : DetoxActionHandler {
 
     override fun handle(params: String, messageId: Long) {
-        val data = mutableMapOf<String, Any?>()
-        data["status"] = formatStatus(testEngineFacade.getBusyIdlingResources())
-
+        val busyResources = testEngineFacade.getAllBusyResources()
+        val data = mapOf<String, Any?>(
+            "status" to formatStatus(busyResources)
+        )
         outboundServerAdapter.sendMessage("currentStatusResult", data, messageId)
     }
 
-    private fun formatStatus(busyResources: List<IdlingResource>): Map<String, Any> {
+    private fun formatStatus(busyResources: List<DetoxBusyResource>): Map<String, Any> =
         if (busyResources.isEmpty()) {
-            return mapOf("app_status" to "idle")
-        }
-
-        val status = mutableMapOf<String, Any>()
-        status["app_status"] = "busy"
-        status["busy_resources"] = busyResources.map{ formatResource(it) }
-
-        return status
-    }
-
-    private fun formatResource(resource: IdlingResource): Map<String, Any> {
-        if (resource is DescriptiveIdlingResource) {
-            return resource.getDescription().json()
-        }
-
-        if (resource.javaClass.name.contains("LooperIdlingResource")) {
-            return formatLooperResourceFromName(resource.name)
-        }
-
-        return mapOf<String, Any>(
-            "name" to "unknown",
-            "description" to mapOf<String, Any>(
-                "identifier" to resource.name
+            mapOf("app_status" to "idle")
+        } else {
+            mapOf(
+                "app_status" to "busy",
+                "busy_resources" to busyResources.map { it.getDescription().json() }
             )
-        )
-    }
-
-    private fun formatLooperResourceFromName(resourceName: String): Map<String, Any> {
-        return when {
-            isJSCodeExecution(resourceName) -> {
-                formatLooperResource(
-                    "\"${resourceName}\" (JS Thread)",
-                    "JavaScript code"
-                )
-            }
-            isNativeCodeExecution(resourceName) -> {
-                formatLooperResource(
-                    "\"${resourceName}\" (Native Modules Thread)",
-                    "native module calls"
-                )
-            }
-            else -> {
-                formatLooperResource(
-                    "\"${resourceName}\""
-                )
-            }
         }
-    }
-
-    /**
-     * @see URL https://reactnative.dev/docs/profiling
-     */
-    private fun isJSCodeExecution(looperName: String): Boolean {
-        return looperName.contains("mqt_js")
-    }
-
-    private fun formatLooperResource(thread: String, executionType: String? = null): Map<String, Any> {
-        return mapOf<String, Any>(
-            "name" to "looper",
-            "description" to
-                    mutableMapOf<String, Any>(
-                        "thread" to thread
-                    ).apply {
-                        if (executionType != null) put("execution_type", executionType)
-                    }
-        )
-    }
-
-    /**
-     * @see URL https://reactnative.dev/docs/profiling
-     */
-    private fun isNativeCodeExecution(looperName: String): Boolean {
-        return looperName.contains("mqt_native")
-    }
 }
diff --git a/detox/android/detox/src/full/java/com/wix/detox/espresso/common/UiControllerImplReflected.kt b/detox/android/detox/src/full/java/com/wix/detox/espresso/common/UiControllerImplReflected.kt
new file mode 100644
index 000000000..4003dc4c1
--- /dev/null
+++ b/detox/android/detox/src/full/java/com/wix/detox/espresso/common/UiControllerImplReflected.kt
@@ -0,0 +1,16 @@
+package com.wix.detox.espresso.common
+
+import com.wix.detox.espresso.action.common.utils.getUiController
+import org.joor.Reflect
+
+private const val FIELD_ASYNC_IDLE = "asyncIdle"
+private const val FIELD_COMPAT_IDLE = "compatIdle"
+private const val METHOD_IS_IDLE_NOW = "isIdleNow"
+
+class UiControllerImplReflected {
+    fun isAsyncIdleNow(): Boolean =
+        Reflect.on(getUiController()).field(FIELD_ASYNC_IDLE).call(METHOD_IS_IDLE_NOW).get()
+
+    fun isCompatIdleNow(): Boolean =
+        Reflect.on(getUiController()).field(FIELD_COMPAT_IDLE).call(METHOD_IS_IDLE_NOW).get()
+}
diff --git a/detox/android/detox/src/full/java/com/wix/detox/espresso/idlingresources/DescriptiveIdlingResource.kt b/detox/android/detox/src/full/java/com/wix/detox/espresso/idlingresources/DescriptiveIdlingResource.kt
new file mode 100644
index 000000000..605d3ef0f
--- /dev/null
+++ b/detox/android/detox/src/full/java/com/wix/detox/espresso/idlingresources/DescriptiveIdlingResource.kt
@@ -0,0 +1,8 @@
+package com.wix.detox.espresso.idlingresources
+
+import androidx.test.espresso.IdlingResource
+
+interface DescriptiveIdlingResource: IdlingResource {
+    fun getDebugName(): String
+    fun getBusyHint(): Map<String, Any>? = null
+}
diff --git a/detox/android/detox/src/full/java/com/wix/detox/espresso/registry/BusyResourcesInquirer.kt b/detox/android/detox/src/full/java/com/wix/detox/espresso/registry/BusyResourcesInquirer.kt
new file mode 100644
index 000000000..0a6ce0470
--- /dev/null
+++ b/detox/android/detox/src/full/java/com/wix/detox/espresso/registry/BusyResourcesInquirer.kt
@@ -0,0 +1,48 @@
+package com.wix.detox.espresso.registry
+
+import androidx.test.espresso.Espresso
+import androidx.test.espresso.base.IdlingResourceRegistry
+import com.wix.detox.common.UIThread
+import com.wix.detox.espresso.common.UiControllerImplReflected
+import com.wix.detox.inquiry.DetoxBusyResource
+import com.wix.detox.inquiry.DetoxBusyResource.BusyAsyncTasks
+import com.wix.detox.inquiry.DetoxBusyResource.BusyIdlingResource
+import org.joor.Reflect
+import java.util.concurrent.Callable
+
+// TODO Better to split inquiry to two separate classes running under the single `UIThread.postFirstSync()` we have here.
+class BusyResourcesInquirer(
+    private val registry: IdlingResourceRegistry,
+    private val uiControllerImplReflected: UiControllerImplReflected
+) {
+    fun getAllBusyResources(): List<DetoxBusyResource> {
+        return UIThread.postFirstSync(Callable<List<DetoxBusyResource>> {
+            mutableListOf<DetoxBusyResource>().apply {
+                addAll(getBusyIdlingResources())
+                addAll(getAsyncTasksResource()?.let { listOf(it) } ?: emptyList())
+            }
+        })
+    }
+
+    private fun getBusyIdlingResources(): List<BusyIdlingResource> =
+        registry.resources
+            .filter { !it.isIdleNow }
+            .map { BusyIdlingResource(it) }
+
+    private fun getAsyncTasksResource(): BusyAsyncTasks? {
+        // Mind the implicit optimization:
+        // Don't inspect both resources if not necessary (async-tasks is not idle).
+        if (uiControllerImplReflected.isAsyncIdleNow() &&
+            uiControllerImplReflected.isCompatIdleNow()) {
+            return null
+        }
+        return BusyAsyncTasks
+    }
+
+    companion object {
+        val INSTANCE = BusyResourcesInquirer(getRegistryDefault(), UiControllerImplReflected())
+    }
+}
+
+private fun getRegistryDefault() =
+    Reflect.on(Espresso::class.java).get<IdlingResourceRegistry>("baseRegistry")
diff --git a/detox/android/detox/src/full/java/com/wix/detox/espresso/registry/IRStatusInquirer.kt b/detox/android/detox/src/full/java/com/wix/detox/espresso/registry/IRStatusInquirer.kt
deleted file mode 100644
index ccefdca9e..000000000
--- a/detox/android/detox/src/full/java/com/wix/detox/espresso/registry/IRStatusInquirer.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-package com.wix.detox.espresso.registry
-
-import androidx.test.espresso.Espresso
-import androidx.test.espresso.IdlingResource
-import androidx.test.espresso.base.IdlingResourceRegistry
-import com.wix.detox.common.UIThread
-import org.joor.Reflect
-import java.util.concurrent.Callable
-
-class IRStatusInquirer(private val registry: IdlingResourceRegistry) {
-    fun getAllBusyResources(): List<IdlingResource> {
-        return UIThread.postFirstSync(Callable<List<IdlingResource>> {
-            registry.resources.filter { resource ->
-                !resource.isIdleNow
-            }
-        })
-    }
-
-    companion object {
-        val INSTANCE = IRStatusInquirer(getRegistryDefault())
-    }
-}
-
-private fun getRegistryDefault() = Reflect.on(Espresso::class.java).get<IdlingResourceRegistry>("baseRegistry")
diff --git a/detox/android/detox/src/full/java/com/wix/detox/inquiry/DetoxBusyResource.kt b/detox/android/detox/src/full/java/com/wix/detox/inquiry/DetoxBusyResource.kt
new file mode 100644
index 000000000..9dcaa418d
--- /dev/null
+++ b/detox/android/detox/src/full/java/com/wix/detox/inquiry/DetoxBusyResource.kt
@@ -0,0 +1,92 @@
+package com.wix.detox.inquiry
+
+import androidx.test.espresso.IdlingResource
+import com.wix.detox.espresso.idlingresources.DescriptiveIdlingResource
+
+sealed class DetoxBusyResource {
+    abstract fun getDescription(): DetoxBusyResourceDescription
+
+    class BusyIdlingResource(val resource: IdlingResource): DetoxBusyResource() {
+        override fun getDescription() =
+            when {
+                (resource is DescriptiveIdlingResource) ->
+                    getIRDescription(resource)
+
+                (resource.javaClass.name.contains("LooperIdlingResource")) ->
+                    getLooperResourceDescriptionByName(resource.name)
+
+                else ->
+                    getUnspecifiedResourceDescription(resource)
+            }
+
+        private fun getIRDescription(resource: DescriptiveIdlingResource) =
+            DetoxBusyResourceDescription.Builder()
+                .name(resource.getDebugName())
+                .apply {
+                    resource.getBusyHint()?.let {
+                        it.forEach { hint -> addDescription(hint.key, hint.value) }
+                    }
+                }
+                .build()
+
+        private fun getLooperResourceDescriptionByName(resourceName: String) =
+            when {
+                isJSCodeExecution(resourceName) -> {
+                    getLooperResourceDesc(
+                        thread = "\"${resourceName}\" (JS Thread)",
+                        executionType = "JavaScript code"
+                    )
+                }
+                isNativeCodeExecution(resourceName) -> {
+                    getLooperResourceDesc(
+                        thread = "\"${resourceName}\" (Native Modules Thread)",
+                        executionType = "native module calls"
+                    )
+                }
+                else -> {
+                    getLooperResourceDesc(
+                        thread = "\"${resourceName}\""
+                    )
+                }
+            }
+
+        private fun getUnspecifiedResourceDescription(resource: IdlingResource) =
+            DetoxBusyResourceDescription.Builder()
+                .name("unknown")
+                .addDescription("identifier", resource.name)
+                .build()
+
+        /**
+         * @see URL https://reactnative.dev/docs/profiling
+         */
+        private fun isJSCodeExecution(looperName: String): Boolean {
+            return looperName.contains("mqt_js")
+        }
+
+        private fun getLooperResourceDesc(thread: String, executionType: String? = null) =
+            DetoxBusyResourceDescription.Builder()
+                .name("looper")
+                .addDescription("thread", thread)
+                .apply {
+                    executionType?.let { addDescription("execution_type", executionType) }
+                }
+                .build()
+
+        /**
+         * @see URL https://reactnative.dev/docs/profiling
+         */
+        private fun isNativeCodeExecution(looperName: String): Boolean {
+            return looperName.contains("mqt_native")
+        }
+
+    }
+
+    object BusyAsyncTasks: DetoxBusyResource() {
+        override fun getDescription() =
+            DetoxBusyResourceDescription.Builder()
+                .name("bg")
+                .addDescription("reason", "native async-tasks")
+                .build()
+
+    }
+}
diff --git a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/IdlingResourceDescription.kt b/detox/android/detox/src/full/java/com/wix/detox/inquiry/DetoxBusyResourceDescription.kt
similarity index 69%
rename from detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/IdlingResourceDescription.kt
rename to detox/android/detox/src/full/java/com/wix/detox/inquiry/DetoxBusyResourceDescription.kt
index 12a21e538..dd1bbf237 100644
--- a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/IdlingResourceDescription.kt
+++ b/detox/android/detox/src/full/java/com/wix/detox/inquiry/DetoxBusyResourceDescription.kt
@@ -1,17 +1,17 @@
-package com.wix.detox.reactnative.idlingresources
+package com.wix.detox.inquiry
 
-class IdlingResourceDescription private constructor(
+class DetoxBusyResourceDescription private constructor(
     private val name: String,
     private val description: Map<String, Any>) {
 
-    fun json() =
+    fun json(): Map<String, Any> =
         mutableMapOf<String, Any>("name" to name)
           .apply {
             if (description.isNotEmpty()) put("description", description)
-          }.toMap()
+          }
 
     override fun equals(other: Any?) =
-      (other is IdlingResourceDescription && other.json() == this.json())
+      (other is DetoxBusyResourceDescription && other.json() == this.json())
 
     override fun hashCode(): Int =
       (31 * name.hashCode() + description.hashCode())
@@ -21,6 +21,6 @@ class IdlingResourceDescription private constructor(
         var description: MutableMap<String, Any> = mutableMapOf()) {
             fun name(value: String) = apply { name = value }
             fun addDescription(key: String, value: Any) = apply { description[key] = value }
-            fun build() = IdlingResourceDescription(name, description)
+            fun build() = DetoxBusyResourceDescription(name, description)
       }
 }
diff --git a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/AnimatedModuleIdlingResource.java b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/AnimatedModuleIdlingResource.java
index f77f37506..738131297 100644
--- a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/AnimatedModuleIdlingResource.java
+++ b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/AnimatedModuleIdlingResource.java
@@ -3,13 +3,17 @@ package com.wix.detox.reactnative.idlingresources;
 import android.util.Log;
 import android.view.Choreographer;
 
+import com.wix.detox.espresso.idlingresources.DescriptiveIdlingResource;
 import com.wix.detox.reactnative.ReactNativeInfo;
 
-import org.jetbrains.annotations.NotNull;
 import org.joor.Reflect;
 import org.joor.ReflectException;
 
+import java.util.HashMap;
+import java.util.Map;
+
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 
 /**
  * Created by simonracz on 25/08/2017.
@@ -51,6 +55,10 @@ public class AnimatedModuleIdlingResource implements DescriptiveIdlingResource,
 
     private final static String METHOD_HAS_ACTIVE_ANIMATIONS = "hasActiveAnimations";
 
+    private final static Map<String, Object> busyHint = new HashMap<String, Object>() {{
+        put("reason", "Animations running on screen");
+    }};
+
     private ResourceCallback callback = null;
     private Object reactContext = null;
 
@@ -63,13 +71,16 @@ public class AnimatedModuleIdlingResource implements DescriptiveIdlingResource,
         return AnimatedModuleIdlingResource.class.getName();
     }
 
-    @NotNull
+    @NonNull
+    @Override
+    public String getDebugName() {
+        return "ui";
+    }
+
+    @Nullable
     @Override
-    public IdlingResourceDescription getDescription() {
-        return new IdlingResourceDescription.Builder()
-                .name("ui")
-                .addDescription("reason", "Animations running on screen")
-                .build();
+    public Map<String, Object> getBusyHint() {
+        return busyHint;
     }
 
     @Override
diff --git a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/AsyncStorageIdlingResource.kt b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/AsyncStorageIdlingResource.kt
index e1cfcdb3c..a05183072 100644
--- a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/AsyncStorageIdlingResource.kt
+++ b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/AsyncStorageIdlingResource.kt
@@ -4,6 +4,7 @@ import android.util.Log
 import androidx.test.espresso.IdlingResource
 import com.facebook.react.bridge.NativeModule
 import com.facebook.react.bridge.ReactContext
+import com.wix.detox.espresso.idlingresources.DescriptiveIdlingResource
 import com.wix.detox.reactnative.helpers.RNHelpers
 import org.joor.Reflect
 import java.util.concurrent.Executor
@@ -50,10 +51,7 @@ open class AsyncStorageIdlingResource
     }
 
     override fun getName(): String = javaClass.name
-
-    override fun getDescription(): IdlingResourceDescription {
-        return IdlingResourceDescription.Builder().name("io").build()
-    }
+    override fun getDebugName() = "io"
 
     override fun isIdleNow(): Boolean =
         checkIdle().also { idle ->
diff --git a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/BridgeIdlingResource.java b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/BridgeIdlingResource.java
index 69ba3cc63..cbb097931 100644
--- a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/BridgeIdlingResource.java
+++ b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/BridgeIdlingResource.java
@@ -5,12 +5,12 @@ import android.util.Log;
 import com.facebook.react.bridge.NotThreadSafeBridgeIdleDebugListener;
 import com.facebook.react.bridge.ReactContext;
 
-import org.jetbrains.annotations.NotNull;
-
-import java.util.HashMap;
 import java.util.Map;
 import java.util.concurrent.atomic.AtomicBoolean;
 
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
 /**
  * Created by simonracz on 01/06/2017.
  */
@@ -42,10 +42,10 @@ public class BridgeIdlingResource extends DetoxBaseIdlingResource implements Not
         return BridgeIdlingResource.class.getName();
     }
 
-    @NotNull
+    @NonNull
     @Override
-    public IdlingResourceDescription getDescription() {
-        return new IdlingResourceDescription.Builder().name("bridge").build();
+    public String getDebugName() {
+        return "bridge";
     }
 
     @Override
@@ -85,4 +85,10 @@ public class BridgeIdlingResource extends DetoxBaseIdlingResource implements Not
             callback.onTransitionToIdle();
         }
     }
+
+    @Nullable
+    @Override
+    public Map<String, Object> getBusyHint() {
+        return null;
+    }
 }
diff --git a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/DescriptiveIdlingResource.kt b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/DescriptiveIdlingResource.kt
deleted file mode 100644
index a2f2d6f44..000000000
--- a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/DescriptiveIdlingResource.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-package com.wix.detox.reactnative.idlingresources
-
-import androidx.test.espresso.IdlingResource
-
-interface DescriptiveIdlingResource: IdlingResource {
-    /**
-     * Returns a descriptive representation of the resource.
-     */
-    fun getDescription(): IdlingResourceDescription
-}
diff --git a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/DetoxBaseIdlingResource.java b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/DetoxBaseIdlingResource.java
index 168f7c5ff..4e76f7530 100644
--- a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/DetoxBaseIdlingResource.java
+++ b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/DetoxBaseIdlingResource.java
@@ -1,5 +1,7 @@
 package com.wix.detox.reactnative.idlingresources;
 
+import com.wix.detox.espresso.idlingresources.DescriptiveIdlingResource;
+
 import java.util.concurrent.atomic.AtomicBoolean;
 
 public abstract class DetoxBaseIdlingResource implements DescriptiveIdlingResource {
diff --git a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/NetworkIdlingResource.java b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/NetworkIdlingResource.java
index 93cd62f34..219010fa7 100644
--- a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/NetworkIdlingResource.java
+++ b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/NetworkIdlingResource.java
@@ -5,16 +5,17 @@ import android.view.Choreographer;
 
 import com.facebook.react.bridge.ReactContext;
 
-import org.jetbrains.annotations.NotNull;
-
 import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 import java.util.regex.Pattern;
 import java.util.regex.PatternSyntaxException;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import okhttp3.Call;
 import okhttp3.Dispatcher;
 
@@ -67,13 +68,18 @@ public class NetworkIdlingResource extends DetoxBaseIdlingResource implements Ch
         return NetworkIdlingResource.class.getName();
     }
 
-    @NotNull
+    @NonNull
+    @Override
+    public String getDebugName() {
+        return "network";
+    }
+
+    @Nullable
     @Override
-    public synchronized IdlingResourceDescription getDescription() {
-        return new IdlingResourceDescription.Builder()
-                .name("network")
-                .addDescription("urls", new ArrayList<>(busyResources))
-                .build();
+    public synchronized Map<String, Object> getBusyHint() {
+        return new HashMap<String, Object>() {{
+            put("urls", new ArrayList<>(busyResources));
+        }};
     }
 
     @Override
diff --git a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/timers/TimersIdlingResource.kt b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/timers/TimersIdlingResource.kt
index ad3229f0e..824bb8d57 100644
--- a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/timers/TimersIdlingResource.kt
+++ b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/timers/TimersIdlingResource.kt
@@ -2,9 +2,7 @@ package com.wix.detox.reactnative.idlingresources.timers
 
 import android.view.Choreographer
 import androidx.test.espresso.IdlingResource
-
 import com.wix.detox.reactnative.idlingresources.DetoxBaseIdlingResource
-import com.wix.detox.reactnative.idlingresources.IdlingResourceDescription
 
 class TimersIdlingResource @JvmOverloads constructor(
         private val interrogationStrategy: IdleInterrogationStrategy,
@@ -14,10 +12,7 @@ class TimersIdlingResource @JvmOverloads constructor(
     private var callback: IdlingResource.ResourceCallback? = null
 
     override fun getName(): String = this.javaClass.name
-
-    override fun getDescription(): IdlingResourceDescription {
-        return IdlingResourceDescription.Builder().name("timers").build();
-    }
+    override fun getDebugName(): String = "timers"
 
     override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?) {
         this.callback = callback
diff --git a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/uimodule/UIManagerModuleReflected.kt b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/uimodule/UIManagerModuleReflected.kt
index fb095ec78..b93ef51c2 100644
--- a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/uimodule/UIManagerModuleReflected.kt
+++ b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/uimodule/UIManagerModuleReflected.kt
@@ -9,12 +9,12 @@ import org.joor.Reflect
 private const val CLASS_UI_MANAGER_MODULE = "com.facebook.react.uimanager.UIManagerModule"
 private const val METHOD_GET_NATIVE_MODULE = "getNativeModule"
 private const val METHOD_GET_UI_IMPLEMENTATION = "getUIImplementation"
+private const val METHOD_IS_EMPTY = "isEmpty"
 private const val FIELD_UI_OPERATION_QUEUE = "mOperationsQueue"
 private const val FIELD_DISPATCH_RUNNABLES = "mDispatchUIRunnables"
 private const val FIELD_DISPATCH_RUNNABLES_LOCK = "mDispatchRunnablesLock"
 private const val FIELD_NON_BATCHED_OPS = "mNonBatchedOperations"
 private const val FIELD_NON_BATCHED_OPS_LOCK = "mNonBatchedOperationsLock"
-private const val METHOD_IS_EMPTY = "isEmpty"
 
 class UIManagerModuleReflected(private val reactContext: ReactContext) {
 
diff --git a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/uimodule/UIModuleIdlingResource.kt b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/uimodule/UIModuleIdlingResource.kt
index fb4b82002..0722d49a1 100644
--- a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/uimodule/UIModuleIdlingResource.kt
+++ b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/uimodule/UIModuleIdlingResource.kt
@@ -6,7 +6,6 @@ import androidx.test.espresso.IdlingResource.ResourceCallback
 import com.facebook.react.bridge.ReactContext
 import com.wix.detox.reactnative.helpers.RNHelpers
 import com.wix.detox.reactnative.idlingresources.DetoxBaseIdlingResource
-import com.wix.detox.reactnative.idlingresources.IdlingResourceDescription
 import org.joor.ReflectException
 
 /**
@@ -21,11 +20,10 @@ class UIModuleIdlingResource(private val reactContext: ReactContext)
     private var callback: ResourceCallback? = null
 
     override fun getName(): String = UIModuleIdlingResource::class.java.name
-    override fun getDescription() =
-            IdlingResourceDescription.Builder()
-                .name("ui")
-                .addDescription("reason", "UI rendering activity")
-                .build()
+    override fun getDebugName(): String = " ui"
+    override fun getBusyHint(): Map<String, Any> {
+        return mapOf("reason" to "UI rendering activity")
+    }
 
     override fun checkIdle(): Boolean {
         try {
diff --git a/detox/src/client/actions/SyncStatusSchema.json b/detox/src/client/actions/SyncStatusSchema.json
index 3d0203ff5..5d484ed63 100644
--- a/detox/src/client/actions/SyncStatusSchema.json
+++ b/detox/src/client/actions/SyncStatusSchema.json
@@ -232,6 +232,27 @@
                 ],
                 "additionalProperties":false
               },
+              {
+                "properties":{
+                  "name":{
+                    "const":"bg"
+                  },
+                  "description":{
+                    "type":"object",
+                    "properties":{
+                      "reason":{
+                        "type":"string"
+                      }
+                    },
+                    "additionalProperties":false
+                  }
+                },
+                "required":[
+                  "name",
+                  "description"
+                ],
+                "additionalProperties":false
+              },
               {
                 "properties":{
                   "name":{
diff --git a/detox/src/client/actions/formatters/SyncStatusFormatter.js b/detox/src/client/actions/formatters/SyncStatusFormatter.js
index b3a5ed1dd..faa9c02df 100644
--- a/detox/src/client/actions/formatters/SyncStatusFormatter.js
+++ b/detox/src/client/actions/formatters/SyncStatusFormatter.js
@@ -4,6 +4,7 @@ const Ajv = require('ajv');
 const DetoxInternalError = require('../../../errors/DetoxInternalError');
 const statusSchema = require('../SyncStatusSchema.json');
 
+const bgThreadFormatter = require('./sync-resources/BgThreadFormatter');
 const delayedPerformSelectorFormatter = require('./sync-resources/DelayedPerformSelectorFormatter');
 const dispatchQueueFormatter = require('./sync-resources/DispatchQueueFormatter');
 const jsTimersFormatter = require('./sync-resources/JavaScriptTimersFormatter');
@@ -51,6 +52,7 @@ function resourcesDescriptionsFromJSON(jsonDescriptions) {
 }
 
 const resourceFormatters = {
+  bg: bgThreadFormatter,
   delayed_perform_selector: delayedPerformSelectorFormatter,
   dispatch_queue: dispatchQueueFormatter,
   run_loop: runLoopFormatter,
diff --git a/detox/src/client/actions/formatters/sync-resources/BgThreadFormatter.js b/detox/src/client/actions/formatters/sync-resources/BgThreadFormatter.js
new file mode 100644
index 000000000..479cf336c
--- /dev/null
+++ b/detox/src/client/actions/formatters/sync-resources/BgThreadFormatter.js
@@ -0,0 +1,5 @@
+const { makeResourceTitle } = require('./utils');
+
+module.exports = function(properties) {
+  return `${makeResourceTitle(`Background work taking place in ${properties.reason}.`)}`;
+};


Test patch:
diff --git a/detox/android/detox/src/full/java/com/wix/detox/TestEngineFacade.kt b/detox/android/detox/src/full/java/com/wix/detox/TestEngineFacade.kt
index 7f6b65da1..b1635c2a0 100644
--- a/detox/android/detox/src/full/java/com/wix/detox/TestEngineFacade.kt
+++ b/detox/android/detox/src/full/java/com/wix/detox/TestEngineFacade.kt
@@ -5,16 +5,16 @@ import android.util.Log
 import androidx.test.espresso.Espresso
 import com.wix.detox.common.DetoxLog.Companion.LOG_TAG
 import com.wix.detox.espresso.UiAutomatorHelper
-import com.wix.detox.espresso.registry.IRStatusInquirer
+import com.wix.detox.espresso.registry.BusyResourcesInquirer
 import com.wix.detox.reactnative.ReactNativeExtension
 
 class TestEngineFacade {
-    fun awaitIdle(): Unit? = Espresso.onIdle() {
+    fun awaitIdle(): Unit? = Espresso.onIdle {
         Log.i(LOG_TAG, "Wait is over: App is now idle!")
         null
     }
     fun syncIdle() = UiAutomatorHelper.espressoSync() // TODO Check whether this can be replaced with #awaitIdle()
-    fun getBusyIdlingResources() = IRStatusInquirer.INSTANCE.getAllBusyResources()
+    fun getAllBusyResources() = BusyResourcesInquirer.INSTANCE.getAllBusyResources()
 
     // TODO Refactor RN related stuff away
     fun reloadReactNative(appContext: Context) = ReactNativeExtension.reloadReactNative(appContext)
diff --git a/detox/android/detox/src/testFull/java/com/wix/detox/adapters/server/QueryStatusActionHandlerSpec.kt b/detox/android/detox/src/testFull/java/com/wix/detox/adapters/server/QueryStatusActionHandlerSpec.kt
index 1299464e4..be74d5f69 100644
--- a/detox/android/detox/src/testFull/java/com/wix/detox/adapters/server/QueryStatusActionHandlerSpec.kt
+++ b/detox/android/detox/src/testFull/java/com/wix/detox/adapters/server/QueryStatusActionHandlerSpec.kt
@@ -1,9 +1,8 @@
 package com.wix.detox.adapters.server
 
-import androidx.test.espresso.IdlingResource
 import com.wix.detox.TestEngineFacade
-import com.wix.detox.reactnative.idlingresources.DescriptiveIdlingResource
-import com.wix.detox.reactnative.idlingresources.IdlingResourceDescription
+import com.wix.detox.inquiry.DetoxBusyResource
+import com.wix.detox.inquiry.DetoxBusyResourceDescription
 import org.mockito.kotlin.eq
 import org.mockito.kotlin.mock
 import org.mockito.kotlin.verify
@@ -31,103 +30,45 @@ object QueryStatusActionHandlerSpec : Spek({
             }
         }
 
-        fun queryStatusHandler() = QueryStatusActionHandler(outboundServerAdapter, testEngineFacade)
+        fun uut() = QueryStatusActionHandler(outboundServerAdapter, testEngineFacade)
 
-        it("should return idle app status") {
-            queryStatusHandler().handle(params, messageId)
-            val expectedData =  mapOf("status" to mapOf("app_status" to "idle"))
-            verify(outboundServerAdapter).sendMessage(eq("currentStatusResult"), eq(expectedData), eq(messageId))
-        }
-
-        fun createMockedDescriptiveResource(description: IdlingResourceDescription):
-                DescriptiveIdlingResource {
-            val resource: DescriptiveIdlingResource = mock()
-            whenever(resource.getDescription()).thenReturn(description)
-            return resource
-        }
-
-        it("should return busy app status with descriptive resource") {
-            val fakeDescription = IdlingResourceDescription.Builder()
-                .name("foo")
-                .addDescription("bar", "baz")
-                .addDescription("qux", "quux")
-                .build()
-
-            val busyResources: List<IdlingResource> = listOf(
-                createMockedDescriptiveResource(fakeDescription)
-            )
-            whenever(testEngineFacade.getBusyIdlingResources()).thenReturn(busyResources)
+        describe("given an idle app") {
+            it("should send an idle status indication") {
+                val expectedData =  mapOf("status" to mapOf("app_status" to "idle"))
 
-            queryStatusHandler().handle(params, messageId)
-
-            val expectedBusyResourceDescription = listOf(
-                mapOf("name" to "foo", "description" to mapOf("bar" to "baz", "qux" to "quux"))
-            )
-            val expectedData =  mapOf("status" to mapOf("app_status" to "busy", "busy_resources" to expectedBusyResourceDescription))
-            verify(outboundServerAdapter).sendMessage(eq("currentStatusResult"), eq(expectedData), eq(messageId))
-        }
-
-        abstract class LooperIdlingResource: IdlingResource {}
-
-        fun createMockedLooperIdlingResource(resourceName: String): LooperIdlingResource {
-            val resource: LooperIdlingResource = mock()
-            whenever(resource.name).thenReturn(resourceName)
-            return resource
+                uut().handle(params, messageId)
+                verify(outboundServerAdapter).sendMessage(eq("currentStatusResult"), eq(expectedData), eq(messageId))
+            }
         }
 
-        it("should return busy app status with looper resources") {
-            val busyResources: List<IdlingResource> = listOf(
-                createMockedLooperIdlingResource("mqt_js"),
-                createMockedLooperIdlingResource("mqt_native"),
-                createMockedLooperIdlingResource("unmapped")
-            )
-            whenever(testEngineFacade.getBusyIdlingResources()).thenReturn(busyResources)
-
-            queryStatusHandler().handle(params, messageId)
+        describe("given a busy app") {
+            fun aBusyResourceDescription(description: Map<String, Any>): DetoxBusyResourceDescription =
+                mock {
+                    on { json() }.thenReturn(description)
+                }
+
+            fun aBusyResource(identifier: String): DetoxBusyResource {
+                val mockedDescription = aBusyResourceDescription(mapOf("mock" to identifier))
+                return mock {
+                    on { getDescription() }.thenReturn(mockedDescription)
+                }
+            }
 
-            val expectedBusyResourceDescription = listOf(
-                mapOf(
-                    "name" to "looper",
-                    "description" to mapOf(
-                        "thread" to "\"mqt_js\" (JS Thread)",
-                        "execution_type" to "JavaScript code"
-                    )
-                ),
-                mapOf(
-                    "name" to "looper",
-                    "description" to mapOf(
-                        "thread" to "\"mqt_native\" (Native Modules Thread)",
-                        "execution_type" to "native module calls"
+            it("should send a descriptive busy-status indication") {
+                val busyResource = aBusyResource("some-resource")
+                val busyResource2 = aBusyResource("yet-another-resource")
+                val expectedData =  mapOf<String, Any>("status" to mapOf(
+                    "app_status" to "busy",
+                    "busy_resources" to listOf(
+                        mapOf("mock" to "some-resource"),
+                        mapOf("mock" to "yet-another-resource"),
                     )
-                ),
-                mapOf(
-                    "name" to "looper",
-                    "description" to mapOf("thread" to "\"unmapped\"")
-                )
-            )
-            val expectedData =  mapOf("status" to mapOf("app_status" to "busy", "busy_resources" to expectedBusyResourceDescription))
-            verify(outboundServerAdapter).sendMessage(eq("currentStatusResult"), eq(expectedData), eq(messageId))
-        }
+                ))
+                whenever(testEngineFacade.getAllBusyResources()).thenReturn(listOf(busyResource, busyResource2))
 
-        fun createMockedIdlingResource(name: String): IdlingResource {
-            val resource: IdlingResource = mock()
-            whenever(resource.name).thenReturn(name)
-            return resource
-        }
-
-        it("should return busy app status with unknown resource") {
-            val busyResources: List<IdlingResource> = listOf(
-                createMockedIdlingResource( "quux")
-            )
-            whenever(testEngineFacade.getBusyIdlingResources()).thenReturn(busyResources)
-
-            queryStatusHandler().handle(params, messageId)
-
-            val expectedBusyResourceDescription = listOf(
-                mapOf("name" to "unknown", "description" to mapOf("identifier" to "quux"))
-            )
-            val expectedData =  mapOf("status" to mapOf("app_status" to "busy", "busy_resources" to expectedBusyResourceDescription))
-            verify(outboundServerAdapter).sendMessage(eq("currentStatusResult"), eq(expectedData), eq(messageId))
+                uut().handle(params, messageId)
+                verify(outboundServerAdapter).sendMessage(eq("currentStatusResult"), eq(expectedData), eq(messageId))
+            }
         }
-    }
+   }
 })
\ No newline at end of file
diff --git a/detox/android/detox/src/testFull/java/com/wix/detox/espresso/registry/IRStatusInquirerTest.kt b/detox/android/detox/src/testFull/java/com/wix/detox/espresso/registry/BusyResourcesInquirerTest.kt
similarity index 62%
rename from detox/android/detox/src/testFull/java/com/wix/detox/espresso/registry/IRStatusInquirerTest.kt
rename to detox/android/detox/src/testFull/java/com/wix/detox/espresso/registry/BusyResourcesInquirerTest.kt
index b2cf4c1b9..33a95cbaa 100644
--- a/detox/android/detox/src/testFull/java/com/wix/detox/espresso/registry/IRStatusInquirerTest.kt
+++ b/detox/android/detox/src/testFull/java/com/wix/detox/espresso/registry/BusyResourcesInquirerTest.kt
@@ -4,6 +4,8 @@ import android.os.Looper
 import androidx.test.espresso.IdlingResource
 import androidx.test.espresso.base.IdlingResourceRegistry
 import com.wix.detox.UTHelpers
+import com.wix.detox.espresso.common.UiControllerImplReflected
+import com.wix.detox.inquiry.DetoxBusyResource
 import org.assertj.core.api.Assertions.assertThat
 import org.junit.Before
 import org.junit.Test
@@ -21,10 +23,11 @@ import java.util.concurrent.Executors
 // Fixes: Hangs in UIThread.postFirstSync inside IRStatusInquirer.getAllBusyResources when upgrading robolectric 4.3.x -> 4.4
 // See http://robolectric.org/blog/2019/06/04/paused-looper/ (coming from https://github.com/robolectric/robolectric/releases/tag/robolectric-4.4)
 @LooperMode(LooperMode.Mode.LEGACY)
-class IRStatusInquirerTest {
+class BusyResourcesInquirerTest {
 
     lateinit var registry: IdlingResourceRegistry
-    lateinit var uut: IRStatusInquirer
+    lateinit var uiController: UiControllerImplReflected
+    lateinit var uut: BusyResourcesInquirer
 
     private fun anIdleResource() = mock<IdlingResource> {
         on { isIdleNow } doReturn true
@@ -38,10 +41,22 @@ class IRStatusInquirerTest {
         whenever(registry.resources).doReturn(resources.asList())
     }
 
+    private fun givenRunningAsyncTasks() {
+        whenever(uiController.isAsyncIdleNow()).doReturn(false)
+    }
+
+    private fun givenRunningCompatAsyncTasks() {
+        whenever(uiController.isCompatIdleNow()).doReturn(false)
+    }
+
     @Before
     fun setup() {
         registry = mock()
-        uut = IRStatusInquirer(registry)
+        uiController = mock() {
+            on { isAsyncIdleNow() } doReturn true
+            on { isCompatIdleNow() } doReturn true
+        }
+        uut = BusyResourcesInquirer(registry, uiController)
     }
 
     @Test
@@ -51,13 +66,33 @@ class IRStatusInquirerTest {
     }
 
     @Test
-    fun `should return busy resources if there are some`() {
+    fun `should return busy resources if there are some IR ones`() {
         val resourceIdle = anIdleResource()
         val resourceBusy = aBusyResource()
         givenIdlingResources(resourceIdle, resourceBusy)
 
         val result = uut.getAllBusyResources()
-        assertThat(result).isEqualTo(arrayListOf(resourceBusy))
+        assertThat(result.size).isEqualTo(1)
+        assertThat(result[0] is DetoxBusyResource.BusyIdlingResource)
+        assertThat((result[0] as DetoxBusyResource.BusyIdlingResource).resource).isEqualTo(resourceBusy)
+    }
+
+    @Test
+    fun `should return the async-task busy resource if some async-tasks are running`() {
+        givenRunningAsyncTasks()
+
+        val result = uut.getAllBusyResources()
+        assertThat(result.size).isEqualTo(1)
+        assertThat(result[0] is DetoxBusyResource.BusyAsyncTasks)
+    }
+
+    @Test
+    fun `should return the async-task busy resource if some legacy async-tasks are running`() {
+        givenRunningCompatAsyncTasks()
+
+        val result = uut.getAllBusyResources()
+        assertThat(result.size).isEqualTo(1)
+        assertThat(result[0] is DetoxBusyResource.BusyAsyncTasks)
     }
 
     @Test
@@ -79,13 +114,17 @@ class IRStatusInquirerTest {
         }
         givenIdlingResources(resource)
 
-        var result: List<IdlingResource>? = null
+        var result: List<DetoxBusyResource>? = null
         executor.execute {
             result = uut.getAllBusyResources()
         }
         Thread.sleep(100L) // Give time for the unit to actually post on the main thread (as it's suppose to...)
         Robolectric.flushForegroundThreadScheduler()
         UTHelpers.yieldToOtherThreads(executor)
-        assertThat(result).isEqualTo(arrayListOf(resource)) // Assert (2)
+
+        assertThat(result).isNotNull
+        assertThat(result!!.size).isEqualTo(1)
+        assertThat(result!![0] is DetoxBusyResource.BusyIdlingResource)
+        assertThat((result!![0] as DetoxBusyResource.BusyIdlingResource).resource).isEqualTo(resource)
     }
 }
diff --git a/detox/android/detox/src/testFull/java/com/wix/detox/reactnative/idlingresources/IdlingResourceDescriptionSpec.kt b/detox/android/detox/src/testFull/java/com/wix/detox/inquiry/DetoxBusyResourceDescriptionSpec.kt
similarity index 78%
rename from detox/android/detox/src/testFull/java/com/wix/detox/reactnative/idlingresources/IdlingResourceDescriptionSpec.kt
rename to detox/android/detox/src/testFull/java/com/wix/detox/inquiry/DetoxBusyResourceDescriptionSpec.kt
index 274faa660..5246fee9f 100644
--- a/detox/android/detox/src/testFull/java/com/wix/detox/reactnative/idlingresources/IdlingResourceDescriptionSpec.kt
+++ b/detox/android/detox/src/testFull/java/com/wix/detox/inquiry/DetoxBusyResourceDescriptionSpec.kt
@@ -1,16 +1,16 @@
-package com.wix.detox.reactnative.idlingresources
+package com.wix.detox.inquiry
 
 import org.assertj.core.api.Assertions
 import org.spekframework.spek2.Spek
 import org.spekframework.spek2.style.specification.describe
 
-class IdlingResourceDescriptionSpec: Spek({
+class DetoxBusyResourceDescriptionSpec: Spek({
     describe("Idling Resource Description Builder") {
-        lateinit var description: IdlingResourceDescription
+        lateinit var description: DetoxBusyResourceDescription
         lateinit var expectedJSON: Map<String, Any>
 
         it("should build with given description") {
-            description = IdlingResourceDescription.Builder()
+            description = DetoxBusyResourceDescription.Builder()
                 .name("foo")
                 .addDescription("bar", "baz")
                 .addDescription("qux", "quux")
@@ -24,14 +24,14 @@ class IdlingResourceDescriptionSpec: Spek({
         }
 
         it("should build without description") {
-            description = IdlingResourceDescription.Builder().name("foo").build()
+            description = DetoxBusyResourceDescription.Builder().name("foo").build()
 
             expectedJSON = mapOf("name" to "foo")
             Assertions.assertThat(description.json()).isEqualTo(expectedJSON)
         }
 
         it("should build without name") {
-            description = IdlingResourceDescription.Builder()
+            description = DetoxBusyResourceDescription.Builder()
                 .addDescription("bar", "baz")
                 .addDescription("qux", "quux")
                 .build()
diff --git a/detox/android/detox/src/testFull/java/com/wix/detox/inquiry/DetoxBusyResourceSpec.kt b/detox/android/detox/src/testFull/java/com/wix/detox/inquiry/DetoxBusyResourceSpec.kt
new file mode 100644
index 000000000..8daba2fc2
--- /dev/null
+++ b/detox/android/detox/src/testFull/java/com/wix/detox/inquiry/DetoxBusyResourceSpec.kt
@@ -0,0 +1,134 @@
+package com.wix.detox.inquiry
+
+import androidx.test.espresso.IdlingResource
+import com.wix.detox.espresso.idlingresources.DescriptiveIdlingResource
+import org.assertj.core.api.Assertions.assertThat
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.spekframework.spek2.Spek
+import org.spekframework.spek2.style.specification.describe
+
+class DetoxBusyResourceSpec: Spek({
+    describe("Detox busy resource") {
+        data class TestCase<in T: IdlingResource>(
+            val caseTitle: String,
+            val idlingResource: IdlingResource,
+            val expectedDescription: DetoxBusyResourceDescription
+        )
+
+        describe("given a descriptive idling resource") {
+            val mockedDebugName = "mock:debug-name"
+
+            fun aDescriptiveIdlingResource(busyHint: Map<String, Any>?): DescriptiveIdlingResource =
+                mock() {
+                    on { getDebugName() }.doReturn(mockedDebugName)
+                    on { getBusyHint() }.doReturn(busyHint)
+                }
+
+            listOf(
+                TestCase<IdlingResource>(
+                    caseTitle = "should return a description based on debug-name and busy-hint",
+                    idlingResource = aDescriptiveIdlingResource(mapOf("mocked" to "hint", "mocked2" to "hint2")),
+                    expectedDescription = DetoxBusyResourceDescription.Builder()
+                        .name(mockedDebugName)
+                        .addDescription("mocked", "hint")
+                        .addDescription("mocked2", "hint2")
+                        .build()
+                ),
+                TestCase(
+                    caseTitle = "should return a description even without a busy-hint",
+                    idlingResource = aDescriptiveIdlingResource(busyHint = null),
+                    expectedDescription = DetoxBusyResourceDescription.Builder()
+                        .name(mockedDebugName)
+                        .build()
+                ),
+            ).forEach { (caseTitle, idlingResource, expectedDescription)  ->
+                it(caseTitle) {
+                    val uut = DetoxBusyResource.BusyIdlingResource(idlingResource)
+
+                    assertThat(uut.resource).isEqualTo(idlingResource)
+                    assertThat(uut.getDescription()).isEqualTo(expectedDescription)
+                }
+            }
+        }
+
+        describe("given a looper idling resource") {
+
+            abstract class LooperIdlingResource: IdlingResource {}
+
+            fun aLooperIdlingResourceMock(resourceName: String): LooperIdlingResource =
+                mock() {
+                    on { name }.doReturn(resourceName)
+                }
+
+            listOf(
+                TestCase<LooperIdlingResource>(
+                    caseTitle = "should return a tailored description for the js-thread looper",
+                    idlingResource = aLooperIdlingResourceMock("mqt_js"),
+                    expectedDescription = DetoxBusyResourceDescription.Builder()
+                        .name("looper")
+                        .addDescription("thread", "\"mqt_js\" (JS Thread)")
+                        .addDescription("execution_type", "JavaScript code")
+                        .build(),
+                ),
+                TestCase<LooperIdlingResource>(
+                    caseTitle = "should return a tailored description for native-modules thread",
+                    idlingResource = aLooperIdlingResourceMock("mqt_native"),
+                    expectedDescription = DetoxBusyResourceDescription.Builder()
+                        .name("looper")
+                        .addDescription("thread", "\"mqt_native\" (Native Modules Thread)")
+                        .addDescription("execution_type", "native module calls")
+                        .build()
+                ),
+                TestCase<LooperIdlingResource>(
+                    caseTitle = "should return a default description for unspecified looper-threads",
+                    idlingResource = aLooperIdlingResourceMock("unmapped"),
+                    expectedDescription = DetoxBusyResourceDescription.Builder()
+                        .name("looper")
+                        .addDescription("thread", "\"unmapped\"")
+                        .build(),
+                ),
+            ).forEach { (caseTitle, idlingResource, expectedDescription) ->
+                it(caseTitle) {
+                    val uut = DetoxBusyResource.BusyIdlingResource(idlingResource)
+
+                    assertThat(uut.resource).isEqualTo(idlingResource)
+                    assertThat(uut.getDescription()).isEqualTo(expectedDescription)
+                }
+            }
+        }
+
+        describe("given a generic idling resource") {
+            val mockedResourceName = "mock:resource-name"
+
+            listOf(
+                TestCase<IdlingResource>(
+                    caseTitle = "should return a generic description for a generic idling resource",
+                    idlingResource = mock { on { name}.doReturn(mockedResourceName) },
+                    expectedDescription = DetoxBusyResourceDescription.Builder()
+                        .name("unknown")
+                        .addDescription("identifier", mockedResourceName)
+                        .build()
+                )
+            ).forEach { (caseTitle, idlingResource, expectedDescription) ->
+                it (caseTitle) {
+                    val uut = DetoxBusyResource.BusyIdlingResource(idlingResource)
+
+                    assertThat(uut.resource).isEqualTo(idlingResource)
+                    assertThat(uut.getDescription()).isEqualTo(expectedDescription)
+                }
+            }
+        }
+
+        describe("busy async-task(s) description") {
+            it("should return a general description") {
+                val expectedDescription = DetoxBusyResourceDescription.Builder()
+                    .name("bg")
+                    .addDescription("reason", "native async-tasks")
+                    .build()
+
+                assertThat(DetoxBusyResource.BusyAsyncTasks.getDescription()).isEqualTo(expectedDescription)
+            }
+        }
+    }
+})
diff --git a/detox/android/detox/src/testFull/java/com/wix/detox/reactnative/idlingresources/AsyncStorageIdlingResourceSpec.kt b/detox/android/detox/src/testFull/java/com/wix/detox/reactnative/idlingresources/AsyncStorageIdlingResourceSpec.kt
index fae40aa73..59556ff0c 100644
--- a/detox/android/detox/src/testFull/java/com/wix/detox/reactnative/idlingresources/AsyncStorageIdlingResourceSpec.kt
+++ b/detox/android/detox/src/testFull/java/com/wix/detox/reactnative/idlingresources/AsyncStorageIdlingResourceSpec.kt
@@ -61,6 +61,10 @@ class AsyncStorageIdlingResourceSpec: Spek({
             assertThat(uut.name).isEqualTo("com.wix.detox.reactnative.idlingresources.AsyncStorageIdlingResource")
         }
 
+        it("should have a debug-name") {
+            assertThat(uut.getDebugName()).isEqualTo("io")
+        }
+
         describe("idle-checking") {
             it("should be idle") {
                 givenIdleSExecutor()
@@ -71,11 +75,6 @@ class AsyncStorageIdlingResourceSpec: Spek({
                 givenAnActiveTask()
                 givenNoPendingTasks()
                 assertThat(uut.isIdleNow).isFalse()
-
-                val expectedDescription = IdlingResourceDescription.Builder()
-                    .name("io")
-                    .build()
-                assertThat(uut.getDescription()).isEqualTo(expectedDescription)
             }
 
             it("should be busy if executor has pending tasks") {
diff --git a/detox/android/detox/src/testFull/java/com/wix/detox/reactnative/idlingresources/NetworkIdlingResourcesTest.kt b/detox/android/detox/src/testFull/java/com/wix/detox/reactnative/idlingresources/NetworkIdlingResourcesTest.kt
index 64b07dd5b..6f863e2e4 100644
--- a/detox/android/detox/src/testFull/java/com/wix/detox/reactnative/idlingresources/NetworkIdlingResourcesTest.kt
+++ b/detox/android/detox/src/testFull/java/com/wix/detox/reactnative/idlingresources/NetworkIdlingResourcesTest.kt
@@ -24,20 +24,20 @@ class NetworkIdlingResourcesTest {
     // Note: Ideally, we should test that the list of busy resources is protected,
     // rather than testing thread-safety as a whole.
     @Test
-    fun `should return the description in a thread-safe way`() {
+    fun `should return the descriptive data a thread-safe way`() {
         val localExecutor = Executors.newSingleThreadExecutor()
-        var description: IdlingResourceDescription? = null
+        var busyHint: Map<String, Any>? = null
 
         synchronized(uut) {
             localExecutor.submit {
-                description = uut.getDescription()
+                busyHint = uut.getBusyHint()
             }
 
             yieldToOtherThreads(localExecutor)
-            assertThat(description).isNull()
+            assertThat(busyHint).isNull()
         }
         yieldToOtherThreads(localExecutor)
-        assertThat(description).isNotNull()
+        assertThat(busyHint).isNotNull
     }
 
     // Note: Ideally, we should test that the list of busy resources is protected,
diff --git a/detox/android/detox/src/testFull/java/com/wix/detox/reactnative/idlingresources/timers/TimersIdlingResourceSpec.kt b/detox/android/detox/src/testFull/java/com/wix/detox/reactnative/idlingresources/timers/TimersIdlingResourceSpec.kt
index 2b63203a8..dcc14da03 100644
--- a/detox/android/detox/src/testFull/java/com/wix/detox/reactnative/idlingresources/timers/TimersIdlingResourceSpec.kt
+++ b/detox/android/detox/src/testFull/java/com/wix/detox/reactnative/idlingresources/timers/TimersIdlingResourceSpec.kt
@@ -2,7 +2,6 @@ package com.wix.detox.reactnative.idlingresources.timers
 
 import android.view.Choreographer
 import androidx.test.espresso.IdlingResource
-import com.wix.detox.reactnative.idlingresources.IdlingResourceDescription
 import org.assertj.core.api.Assertions
 import org.mockito.kotlin.*
 import org.spekframework.spek2.Spek
@@ -41,6 +40,10 @@ object TimersIdlingResourceSpec : Spek({
             getChoreographerCallback().doFrame(0L)
         }
 
+        it("should return a debug-name") {
+            Assertions.assertThat(uut().getDebugName()).isEqualTo("timers")
+        }
+
         it("should be idle if strategy says so") {
             givenIdleStrategy()
             Assertions.assertThat(uut().isIdleNow).isTrue()
@@ -49,11 +52,6 @@ object TimersIdlingResourceSpec : Spek({
         it("should be busy if strategy says so") {
             givenBusyStrategy()
             Assertions.assertThat(uut().isIdleNow).isFalse()
-
-            val expectedDescription = IdlingResourceDescription.Builder()
-                .name("timers")
-                .build()
-            Assertions.assertThat(uut().getDescription()).isEqualTo(expectedDescription)
         }
 
         it("should transition to idle if found idle by strategy") {
diff --git a/detox/src/client/actions/formatters/SyncStatusFormatter.test.js b/detox/src/client/actions/formatters/SyncStatusFormatter.test.js
index 22d803179..b2090f4e7 100644
--- a/detox/src/client/actions/formatters/SyncStatusFormatter.test.js
+++ b/detox/src/client/actions/formatters/SyncStatusFormatter.test.js
@@ -7,7 +7,9 @@ describe('Sync Status Formatter', () => {
         busy_resource: []
       };
 
-      await expect(() => { format(invalidStatus); }).toThrowErrorMatchingSnapshot();
+      await expect(() => {
+        format(invalidStatus);
+      }).toThrowErrorMatchingSnapshot();
     });
 
     it('should throw error when `app_status` is invalid', async () => {
@@ -15,7 +17,9 @@ describe('Sync Status Formatter', () => {
         app_status: 'foo'
       };
 
-      await expect(() => { format(invalidStatus); }).toThrowErrorMatchingSnapshot();
+      await expect(() => {
+        format(invalidStatus);
+      }).toThrowErrorMatchingSnapshot();
     });
 
     it('should throw error when `app_status` is `busy` but `busy_resources` is missing', async () => {
@@ -23,7 +27,9 @@ describe('Sync Status Formatter', () => {
         app_status: 'busy'
       };
 
-      await expect(() => { format(invalidStatus); }).toThrowErrorMatchingSnapshot();
+      await expect(() => {
+        format(invalidStatus);
+      }).toThrowErrorMatchingSnapshot();
     });
 
     it('should throw error when `app_status` is `busy` but `busy_resources` is empty', async () => {
@@ -32,7 +38,9 @@ describe('Sync Status Formatter', () => {
         busy_resources: []
       };
 
-      await expect(() => { format(invalidStatus); }).toThrowErrorMatchingSnapshot();
+      await expect(() => {
+        format(invalidStatus);
+      }).toThrowErrorMatchingSnapshot();
     });
 
     it('should throw error when resource `name` is missing', async () => {
@@ -47,7 +55,9 @@ describe('Sync Status Formatter', () => {
         ]
       };
 
-      await expect(() => { format(invalidStatus); }).toThrowErrorMatchingSnapshot();
+      await expect(() => {
+        format(invalidStatus);
+      }).toThrowErrorMatchingSnapshot();
     });
 
     it('should throw error when a busy resource is invalid', async () => {
@@ -60,7 +70,9 @@ describe('Sync Status Formatter', () => {
         ]
       };
 
-      await expect(() => { format(invalidStatus); }).toThrowErrorMatchingSnapshot();
+      await expect(() => {
+        format(invalidStatus);
+      }).toThrowErrorMatchingSnapshot();
     });
   });
 
@@ -376,5 +388,21 @@ describe('Sync Status Formatter', () => {
 
       await expect(format(busyStatus)).toMatchSnapshot();
     });
+
+    it('should format "bg" correctly', async () => {
+      let busyStatus = {
+        app_status: 'busy',
+        busy_resources: [
+          {
+            name: 'bg',
+            description: {
+              reason: 'asynctasks'
+            }
+          }
+        ]
+      };
+
+      await expect(format(busyStatus)).toMatchSnapshot();
+    });
   });
 });
diff --git a/detox/src/client/actions/formatters/__snapshots__/SyncStatusFormatter.test.js.snap b/detox/src/client/actions/formatters/__snapshots__/SyncStatusFormatter.test.js.snap
index 3b1e7492a..241ec7b1e 100644
--- a/detox/src/client/actions/formatters/__snapshots__/SyncStatusFormatter.test.js.snap
+++ b/detox/src/client/actions/formatters/__snapshots__/SyncStatusFormatter.test.js.snap
@@ -57,9 +57,10 @@ With reasons:
 • must have required property 'description' in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/6/required\\" with params: {\\"missingProperty\\":\\"description\\"}
 • must have required property 'description' in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/7/required\\" with params: {\\"missingProperty\\":\\"description\\"}
 • must have required property 'description' in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/8/required\\" with params: {\\"missingProperty\\":\\"description\\"}
-• must be equal to constant in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/9/properties/name/const\\" with params: {\\"allowedValue\\":\\"io\\"}
-• must be equal to constant in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/10/properties/name/const\\" with params: {\\"allowedValue\\":\\"bridge\\"}
-• must have required property 'description' in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/11/required\\" with params: {\\"missingProperty\\":\\"description\\"}
+• must have required property 'description' in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/9/required\\" with params: {\\"missingProperty\\":\\"description\\"}
+• must be equal to constant in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/10/properties/name/const\\" with params: {\\"allowedValue\\":\\"io\\"}
+• must be equal to constant in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/11/properties/name/const\\" with params: {\\"allowedValue\\":\\"bridge\\"}
+• must have required property 'description' in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/12/required\\" with params: {\\"missingProperty\\":\\"description\\"}
 • must match a schema in anyOf in path \\"#/oneOf/1/properties/busy_resources/items/anyOf\\" with params: {}
 • must match exactly one schema in oneOf in path \\"#/oneOf\\" with params: {\\"passingSchemas\\":null}
 
@@ -83,6 +84,7 @@ With reasons:
 • must have required property 'name' in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/9/required\\" with params: {\\"missingProperty\\":\\"name\\"}
 • must have required property 'name' in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/10/required\\" with params: {\\"missingProperty\\":\\"name\\"}
 • must have required property 'name' in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/11/required\\" with params: {\\"missingProperty\\":\\"name\\"}
+• must have required property 'name' in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/12/required\\" with params: {\\"missingProperty\\":\\"name\\"}
 • must match a schema in anyOf in path \\"#/oneOf/1/properties/busy_resources/items/anyOf\\" with params: {}
 • must match exactly one schema in oneOf in path \\"#/oneOf\\" with params: {\\"passingSchemas\\":null}
 
@@ -90,6 +92,11 @@ Please report this issue on our GitHub tracker:
 https://github.com/wix/Detox/issues"
 `;
 
+exports[`Sync Status Formatter busy status should format "bg" correctly 1`] = `
+"The app is busy with the following tasks:
+• Background work taking place in asynctasks."
+`;
+
 exports[`Sync Status Formatter busy status should format "bridge" correctly 1`] = `
 "The app is busy with the following tasks:
 • Activity on the React-Native bridge."