Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import android.webkit.WebView
import android.widget.TextView
import com.wix.detox.espresso.DeviceDisplay
import com.wix.detox.reactnative.ui.getAccessibilityLabel
import com.wix.detox.inquiry.ViewLifecycleRegistry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.suspendCancellableCoroutine
Expand Down Expand Up @@ -170,6 +171,20 @@ object ViewHierarchyGenerator {
attributes["text"] = view.text.toString()
}

// Inject animation metadata
val animationMetadata = ViewLifecycleRegistry.getAnimationMetadata(view)
if (animationMetadata != null) {
animationMetadata.animated?.let {
attributes["lastAnimated"] = animationMetadata.getAnimationDurationMs()?.toString() ?: "0"
}
animationMetadata.updated?.let {
attributes["lastUpdated"] = animationMetadata.getUpdateDurationMs()?.toString() ?: "0"
}
if (ViewLifecycleRegistry.isAnimating(view)) {
attributes["animating"] = "true"
}
}

val currentTestId = view.tag?.toString() ?: ""

val injectedPrefix = "detox_temp_"
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package com.wix.detox.inquiry

import android.util.Log
import android.view.View
import java.util.concurrent.ConcurrentHashMap
import java.util.Date

/**
* Registry to track view lifecycle events like mounting, updating, and animating.
* This data is used to inject metadata into the XML hierarchy for debugging.
*/
object ViewLifecycleRegistry {
private const val LOG_TAG = "ViewLifecycleRegistry"

// Thread-safe maps to store view lifecycle data
private val mountedViews = ConcurrentHashMap<View, Date>()
private val updatedViews = ConcurrentHashMap<View, Date>()
private val animatedViews = ConcurrentHashMap<View, Date>()
private val customEvents = ConcurrentHashMap<View, MutableList<Pair<String, Date>>>()

/**
* Mark a view as mounted (created/attached)
*/
fun markMounted(view: View) {
val now = Date()
mountedViews[view] = now
Log.d(LOG_TAG, "View mounted: ${view.javaClass.simpleName} at $now")
}

/**
* Mark a view as updated (props changed)
*/
fun markUpdated(view: View) {
val now = Date()
updatedViews[view] = now
Log.d(LOG_TAG, "View updated: ${view.javaClass.simpleName} at $now")
}

/**
* Clear animated views older than 5 seconds (called at start of each inquiry)
*/
fun clearAnimatedViews() {
val now = System.currentTimeMillis()
val fiveSecondsAgo = now - 5000

val iterator = animatedViews.iterator()
var clearedCount = 0

while (iterator.hasNext()) {
val entry = iterator.next()
if (entry.value.time < fiveSecondsAgo) {
iterator.remove()
clearedCount++
}
}

Log.d(LOG_TAG, "Cleared $clearedCount animated views older than 5s, ${animatedViews.size} remaining")
}

/**
* Mark a view as currently animating
*/
fun markAnimated(view: View) {
val now = Date()
animatedViews[view] = now
Log.d(LOG_TAG, "View animating: ${view.javaClass.simpleName} at $now")
}

/**
* Mark a custom event on a view (e.g., specific animated properties)
*/
fun markCustomEvent(view: View, event: String) {
val now = Date()
customEvents.computeIfAbsent(view) { mutableListOf() }.add(event to now)
Log.d(LOG_TAG, "Custom event '$event' on view: ${view.javaClass.simpleName} at $now")
}

/**
* Get animation metadata for a view
*/
fun getAnimationMetadata(view: View): AnimationMetadata? {
val mounted = mountedViews[view]
val updated = updatedViews[view]
val animated = animatedViews[view]
val events = customEvents[view] ?: emptyList()

if (mounted == null && updated == null && animated == null && events.isEmpty()) {
return null
}

return AnimationMetadata(
mounted = mounted,
updated = updated,
animated = animated,
events = events
)
}

/**
* Clear all data (useful for testing)
*/
fun clear() {
mountedViews.clear()
updatedViews.clear()
animatedViews.clear()
customEvents.clear()
Log.d(LOG_TAG, "ViewLifecycleRegistry cleared")
}

/**
* Get all currently animated views
*/
fun getAnimatedViews(): Map<View, Date> = animatedViews.toMap()

/**
* Check if a view is currently animating
*/
fun isAnimating(view: View): Boolean = animatedViews.containsKey(view)
}

/**
* Data class to hold animation metadata for a view
*/
data class AnimationMetadata(
val mounted: Date?,
val updated: Date?,
val animated: Date?,
val events: List<Pair<String, Date>>
) {
/**
* Calculate time since animation started in milliseconds
*/
fun getAnimationDurationMs(): Long? {
return animated?.let { System.currentTimeMillis() - it.time }
}

/**
* Calculate time since last update in milliseconds
*/
fun getUpdateDurationMs(): Long? {
return updated?.let { System.currentTimeMillis() - it.time }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import androidx.annotation.UiThread
import androidx.test.espresso.IdlingResource.ResourceCallback
import com.facebook.react.animated.NativeAnimatedModule
import com.facebook.react.animated.NativeAnimatedNodesManager
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContext
import com.wix.detox.common.DetoxErrors
import com.wix.detox.common.DetoxLog.Companion.LOG_TAG
import com.wix.detox.inquiry.FabricAnimationsInquirer
import com.wix.detox.reactnative.ReactNativeInfo
import com.wix.detox.reactnative.idlingresources.DetoxIdlingResource
import kotlin.reflect.KProperty1
Expand All @@ -34,6 +36,9 @@ class AnimatedModuleIdlingResource(private val reactContext: ReactContext) : Det

if (animatedModule.hasQueuedAnimations() ||
animatedModule.hasActiveAnimations()) {
if (reactContext is ReactApplicationContext) {
FabricAnimationsInquirer.logAnimatingViews(reactContext)
}
Choreographer.getInstance().postFrameCallback(this)
return false
}
Expand Down Expand Up @@ -115,15 +120,15 @@ class OperationsQueueReflected(private val operationsQueue: Any) {
isEmptyMethod.isAccessible = true
return isEmptyMethod.call(operationsQueue) as Boolean
}

// Fallback to property (works in debug builds for RN 0.80+)
val isEmptyProperty = operationsQueue::class.memberProperties.find { it.name == "isEmpty" }
if (isEmptyProperty != null) {
isEmptyProperty.isAccessible = true
@Suppress("UNCHECKED_CAST")
return (isEmptyProperty as KProperty1<Any, *>).get(operationsQueue) as Boolean
}

throw DetoxErrors.DetoxIllegalStateException("isEmpty method/property cannot be reached")
}
}
2 changes: 1 addition & 1 deletion detox/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "detox",
"description": "E2E tests and automation for mobile",
"version": "20.43.0",
"version": "20.44.0-smoke.4",
"bin": {
"detox": "local-cli/cli.js"
},
Expand Down
4 changes: 2 additions & 2 deletions detox/test/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "detox-test",
"version": "20.43.0",
"version": "20.44.0-smoke.4",
"private": true,
"engines": {
"node": ">=18"
Expand Down Expand Up @@ -67,7 +67,7 @@
"@typescript-eslint/parser": "^6.16.0",
"axios": "^1.7.7",
"cross-env": "^7.0.3",
"detox": "^20.43.0",
"detox": "^20.44.0-smoke.4",
"detox-allure2-adapter": "1.0.0-alpha.34",
"eslint": "^8.56.0",
"eslint-plugin-unicorn": "^50.0.1",
Expand Down
4 changes: 2 additions & 2 deletions examples/demo-native-android/package.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
{
"name": "detox-demo-native-android",
"version": "20.43.0",
"version": "20.44.0-smoke.4",
"private": true,
"scripts": {
"packager": "react-native start",
"detox-server": "detox run-server"
},
"devDependencies": {
"detox": "^20.43.0"
"detox": "^20.44.0-smoke.4"
},
"detox": {}
}
4 changes: 2 additions & 2 deletions examples/demo-native-ios/package.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"name": "detox-demo-native-ios",
"version": "20.43.0",
"version": "20.44.0-smoke.4",
"private": true,
"devDependencies": {
"detox": "^20.43.0"
"detox": "^20.44.0-smoke.4"
},
"detox": {
"specs": "",
Expand Down
4 changes: 2 additions & 2 deletions examples/demo-plugin/package.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
{
"name": "demo-plugin",
"version": "20.43.0",
"version": "20.44.0-smoke.4",
"private": true,
"scripts": {
"test:plugin": "detox test --configuration plugin -l verbose"
},
"devDependencies": {
"detox": "^20.43.0",
"detox": "^20.44.0-smoke.4",
"jest": "^30.0.3"
},
"detox": {
Expand Down
4 changes: 2 additions & 2 deletions examples/demo-react-native-detox-instruments/package.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"name": "demo-react-native-detox-instruments",
"version": "20.43.0",
"version": "20.44.0-smoke.4",
"private": true,
"scripts": {},
"devDependencies": {
"detox": "^20.43.0"
"detox": "^20.44.0-smoke.4"
},
"detox": {
"configurations": {
Expand Down
4 changes: 2 additions & 2 deletions examples/demo-react-native/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "example",
"version": "20.43.0",
"version": "20.44.0-smoke.4",
"private": true,
"scripts": {
"start": "react-native start",
Expand Down Expand Up @@ -41,7 +41,7 @@
"@types/jest": "^29.2.1",
"@types/react": "^19.1.0",
"@types/react-test-renderer": "^19.1.0",
"detox": "^20.43.0",
"detox": "^20.44.0-smoke.4",
"fs-extra": "^9.1.0",
"jest": "^30.0.3",
"react-test-renderer": "19.1.0",
Expand Down
2 changes: 1 addition & 1 deletion lerna.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"generation",
"."
],
"version": "20.43.0",
"version": "20.44.0-smoke.4",
"npmClient": "npm",
"command": {
"publish": {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,5 +55,5 @@
"shell-utils": "1.x.x",
"unified": "^10.1.0"
},
"version": "20.43.0"
"version": "20.44.0-smoke.4"
}