From 1f02d05d38dab7db7a4c3c722b1edaae64a79ed9 Mon Sep 17 00:00:00 2001 From: mobileoss Date: Thu, 9 Oct 2025 06:49:27 +0000 Subject: [PATCH 1/8] Publish 20.44.0-smoke.0 [ci skip] --- detox/package.json | 2 +- detox/test/package.json | 4 ++-- examples/demo-native-android/package.json | 4 ++-- examples/demo-native-ios/package.json | 4 ++-- examples/demo-plugin/package.json | 4 ++-- examples/demo-react-native-detox-instruments/package.json | 4 ++-- examples/demo-react-native/package.json | 4 ++-- lerna.json | 2 +- package.json | 2 +- 9 files changed, 15 insertions(+), 15 deletions(-) diff --git a/detox/package.json b/detox/package.json index 92c945b7ad..cae7b8def2 100644 --- a/detox/package.json +++ b/detox/package.json @@ -1,7 +1,7 @@ { "name": "detox", "description": "E2E tests and automation for mobile", - "version": "20.43.0", + "version": "20.44.0-smoke.0", "bin": { "detox": "local-cli/cli.js" }, diff --git a/detox/test/package.json b/detox/test/package.json index 28c06aecce..2f72507d63 100644 --- a/detox/test/package.json +++ b/detox/test/package.json @@ -1,6 +1,6 @@ { "name": "detox-test", - "version": "20.43.0", + "version": "20.44.0-smoke.0", "private": true, "engines": { "node": ">=18" @@ -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.0", "detox-allure2-adapter": "1.0.0-alpha.34", "eslint": "^8.56.0", "eslint-plugin-unicorn": "^50.0.1", diff --git a/examples/demo-native-android/package.json b/examples/demo-native-android/package.json index 3cbb481f93..67286a13a4 100644 --- a/examples/demo-native-android/package.json +++ b/examples/demo-native-android/package.json @@ -1,13 +1,13 @@ { "name": "detox-demo-native-android", - "version": "20.43.0", + "version": "20.44.0-smoke.0", "private": true, "scripts": { "packager": "react-native start", "detox-server": "detox run-server" }, "devDependencies": { - "detox": "^20.43.0" + "detox": "^20.44.0-smoke.0" }, "detox": {} } diff --git a/examples/demo-native-ios/package.json b/examples/demo-native-ios/package.json index 077d6bf162..aa0f2e9616 100644 --- a/examples/demo-native-ios/package.json +++ b/examples/demo-native-ios/package.json @@ -1,9 +1,9 @@ { "name": "detox-demo-native-ios", - "version": "20.43.0", + "version": "20.44.0-smoke.0", "private": true, "devDependencies": { - "detox": "^20.43.0" + "detox": "^20.44.0-smoke.0" }, "detox": { "specs": "", diff --git a/examples/demo-plugin/package.json b/examples/demo-plugin/package.json index 1926594e78..37a32a4818 100644 --- a/examples/demo-plugin/package.json +++ b/examples/demo-plugin/package.json @@ -1,12 +1,12 @@ { "name": "demo-plugin", - "version": "20.43.0", + "version": "20.44.0-smoke.0", "private": true, "scripts": { "test:plugin": "detox test --configuration plugin -l verbose" }, "devDependencies": { - "detox": "^20.43.0", + "detox": "^20.44.0-smoke.0", "jest": "^30.0.3" }, "detox": { diff --git a/examples/demo-react-native-detox-instruments/package.json b/examples/demo-react-native-detox-instruments/package.json index a715d95a86..4c32996b11 100644 --- a/examples/demo-react-native-detox-instruments/package.json +++ b/examples/demo-react-native-detox-instruments/package.json @@ -1,10 +1,10 @@ { "name": "demo-react-native-detox-instruments", - "version": "20.43.0", + "version": "20.44.0-smoke.0", "private": true, "scripts": {}, "devDependencies": { - "detox": "^20.43.0" + "detox": "^20.44.0-smoke.0" }, "detox": { "configurations": { diff --git a/examples/demo-react-native/package.json b/examples/demo-react-native/package.json index f05fe6782b..854ffaf49a 100644 --- a/examples/demo-react-native/package.json +++ b/examples/demo-react-native/package.json @@ -1,6 +1,6 @@ { "name": "example", - "version": "20.43.0", + "version": "20.44.0-smoke.0", "private": true, "scripts": { "start": "react-native start", @@ -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.0", "fs-extra": "^9.1.0", "jest": "^30.0.3", "react-test-renderer": "19.1.0", diff --git a/lerna.json b/lerna.json index 07df56083c..683476fdb2 100644 --- a/lerna.json +++ b/lerna.json @@ -14,7 +14,7 @@ "generation", "." ], - "version": "20.43.0", + "version": "20.44.0-smoke.0", "npmClient": "npm", "command": { "publish": { diff --git a/package.json b/package.json index cdd2a29565..598587ff6a 100644 --- a/package.json +++ b/package.json @@ -55,5 +55,5 @@ "shell-utils": "1.x.x", "unified": "^10.1.0" }, - "version": "20.43.0" + "version": "20.44.0-smoke.0" } From 154784ffb128fea3c0dd8db5025704f9b005de3e Mon Sep 17 00:00:00 2001 From: mobileoss Date: Thu, 9 Oct 2025 10:50:43 +0000 Subject: [PATCH 2/8] Publish 20.44.0-smoke.1 [ci skip] --- detox/package.json | 2 +- detox/test/package.json | 4 ++-- examples/demo-native-android/package.json | 4 ++-- examples/demo-native-ios/package.json | 4 ++-- examples/demo-plugin/package.json | 4 ++-- examples/demo-react-native-detox-instruments/package.json | 4 ++-- examples/demo-react-native/package.json | 4 ++-- lerna.json | 2 +- package.json | 2 +- 9 files changed, 15 insertions(+), 15 deletions(-) diff --git a/detox/package.json b/detox/package.json index cae7b8def2..3d5a38dc64 100644 --- a/detox/package.json +++ b/detox/package.json @@ -1,7 +1,7 @@ { "name": "detox", "description": "E2E tests and automation for mobile", - "version": "20.44.0-smoke.0", + "version": "20.44.0-smoke.1", "bin": { "detox": "local-cli/cli.js" }, diff --git a/detox/test/package.json b/detox/test/package.json index 2f72507d63..502279b7bd 100644 --- a/detox/test/package.json +++ b/detox/test/package.json @@ -1,6 +1,6 @@ { "name": "detox-test", - "version": "20.44.0-smoke.0", + "version": "20.44.0-smoke.1", "private": true, "engines": { "node": ">=18" @@ -67,7 +67,7 @@ "@typescript-eslint/parser": "^6.16.0", "axios": "^1.7.7", "cross-env": "^7.0.3", - "detox": "^20.44.0-smoke.0", + "detox": "^20.44.0-smoke.1", "detox-allure2-adapter": "1.0.0-alpha.34", "eslint": "^8.56.0", "eslint-plugin-unicorn": "^50.0.1", diff --git a/examples/demo-native-android/package.json b/examples/demo-native-android/package.json index 67286a13a4..a033265e70 100644 --- a/examples/demo-native-android/package.json +++ b/examples/demo-native-android/package.json @@ -1,13 +1,13 @@ { "name": "detox-demo-native-android", - "version": "20.44.0-smoke.0", + "version": "20.44.0-smoke.1", "private": true, "scripts": { "packager": "react-native start", "detox-server": "detox run-server" }, "devDependencies": { - "detox": "^20.44.0-smoke.0" + "detox": "^20.44.0-smoke.1" }, "detox": {} } diff --git a/examples/demo-native-ios/package.json b/examples/demo-native-ios/package.json index aa0f2e9616..e70f860644 100644 --- a/examples/demo-native-ios/package.json +++ b/examples/demo-native-ios/package.json @@ -1,9 +1,9 @@ { "name": "detox-demo-native-ios", - "version": "20.44.0-smoke.0", + "version": "20.44.0-smoke.1", "private": true, "devDependencies": { - "detox": "^20.44.0-smoke.0" + "detox": "^20.44.0-smoke.1" }, "detox": { "specs": "", diff --git a/examples/demo-plugin/package.json b/examples/demo-plugin/package.json index 37a32a4818..3b094dc530 100644 --- a/examples/demo-plugin/package.json +++ b/examples/demo-plugin/package.json @@ -1,12 +1,12 @@ { "name": "demo-plugin", - "version": "20.44.0-smoke.0", + "version": "20.44.0-smoke.1", "private": true, "scripts": { "test:plugin": "detox test --configuration plugin -l verbose" }, "devDependencies": { - "detox": "^20.44.0-smoke.0", + "detox": "^20.44.0-smoke.1", "jest": "^30.0.3" }, "detox": { diff --git a/examples/demo-react-native-detox-instruments/package.json b/examples/demo-react-native-detox-instruments/package.json index 4c32996b11..f2acc33b32 100644 --- a/examples/demo-react-native-detox-instruments/package.json +++ b/examples/demo-react-native-detox-instruments/package.json @@ -1,10 +1,10 @@ { "name": "demo-react-native-detox-instruments", - "version": "20.44.0-smoke.0", + "version": "20.44.0-smoke.1", "private": true, "scripts": {}, "devDependencies": { - "detox": "^20.44.0-smoke.0" + "detox": "^20.44.0-smoke.1" }, "detox": { "configurations": { diff --git a/examples/demo-react-native/package.json b/examples/demo-react-native/package.json index 854ffaf49a..50558f8f08 100644 --- a/examples/demo-react-native/package.json +++ b/examples/demo-react-native/package.json @@ -1,6 +1,6 @@ { "name": "example", - "version": "20.44.0-smoke.0", + "version": "20.44.0-smoke.1", "private": true, "scripts": { "start": "react-native start", @@ -41,7 +41,7 @@ "@types/jest": "^29.2.1", "@types/react": "^19.1.0", "@types/react-test-renderer": "^19.1.0", - "detox": "^20.44.0-smoke.0", + "detox": "^20.44.0-smoke.1", "fs-extra": "^9.1.0", "jest": "^30.0.3", "react-test-renderer": "19.1.0", diff --git a/lerna.json b/lerna.json index 683476fdb2..45e2e9c566 100644 --- a/lerna.json +++ b/lerna.json @@ -14,7 +14,7 @@ "generation", "." ], - "version": "20.44.0-smoke.0", + "version": "20.44.0-smoke.1", "npmClient": "npm", "command": { "publish": { diff --git a/package.json b/package.json index 598587ff6a..2b31f4ee54 100644 --- a/package.json +++ b/package.json @@ -55,5 +55,5 @@ "shell-utils": "1.x.x", "unified": "^10.1.0" }, - "version": "20.44.0-smoke.0" + "version": "20.44.0-smoke.1" } From 05b74ecd6a995eca3744461432f63c661b912913 Mon Sep 17 00:00:00 2001 From: Yaroslav Serhieiev Date: Thu, 9 Oct 2025 16:52:10 +0300 Subject: [PATCH 3/8] temp: another solution --- .../hierarchy/ViewHierarchyGenerator.kt | 15 + .../detox/inquiry/FabricAnimationsInquirer.kt | 430 ++++++++++++++++++ .../detox/inquiry/ViewLifecycleRegistry.kt | 143 ++++++ .../AnimatedModuleIdlingResource.kt | 9 +- 4 files changed, 595 insertions(+), 2 deletions(-) create mode 100644 detox/android/detox/src/full/java/com/wix/detox/inquiry/FabricAnimationsInquirer.kt create mode 100644 detox/android/detox/src/full/java/com/wix/detox/inquiry/ViewLifecycleRegistry.kt diff --git a/detox/android/detox/src/full/java/com/wix/detox/espresso/hierarchy/ViewHierarchyGenerator.kt b/detox/android/detox/src/full/java/com/wix/detox/espresso/hierarchy/ViewHierarchyGenerator.kt index 564838a101..92b66ef868 100644 --- a/detox/android/detox/src/full/java/com/wix/detox/espresso/hierarchy/ViewHierarchyGenerator.kt +++ b/detox/android/detox/src/full/java/com/wix/detox/espresso/hierarchy/ViewHierarchyGenerator.kt @@ -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 @@ -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_" diff --git a/detox/android/detox/src/full/java/com/wix/detox/inquiry/FabricAnimationsInquirer.kt b/detox/android/detox/src/full/java/com/wix/detox/inquiry/FabricAnimationsInquirer.kt new file mode 100644 index 0000000000..5ea8a15488 --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/inquiry/FabricAnimationsInquirer.kt @@ -0,0 +1,430 @@ +package com.wix.detox.inquiry + +import android.util.Log +import android.util.SparseArray +import android.view.View +import com.facebook.react.animated.AnimatedNode +import com.facebook.react.animated.NativeAnimatedModule +import com.facebook.react.animated.NativeAnimatedNodesManager +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.common.UIManagerType +import java.lang.reflect.Field +import java.util.LinkedList +import java.util.concurrent.atomic.AtomicReference + +object FabricAnimationsInquirer { + private const val LOG_TAG = "FabricAnimationsInquirer" + + // Cache fields to avoid repeated reflection lookups + private var mActiveAnimationsField: Field? = null + private var mUpdatedNodesField: Field? = null + private var mAnimatedNodesField: Field? = null + private var animatedValueField: Field? = null + private var childrenField: Field? = null + private var connectedViewTagField: Field? = null + private var propNodeMappingField: Field? = null + private var propMappingField: Field? = null + private var nodeValueField: Field? = null + private var offsetField: Field? = null + + fun logAnimatingViews(reactContext: ReactApplicationContext) { + try { + Log.d(LOG_TAG, "Starting animation inquiry...") + + // Clear previous animated views - fresh start for this inquiry + ViewLifecycleRegistry.clearAnimatedViews() + + val nodesManager = getNodesManager(reactContext) ?: return + Log.d(LOG_TAG, "Got nodesManager: ${nodesManager.javaClass.simpleName}") + + // Check if there are any active animations first + val hasActive = nodesManager.hasActiveAnimations() + Log.d(LOG_TAG, "hasActiveAnimations() returned: $hasActive") + + if (!hasActive) { + Log.d(LOG_TAG, "No active animations detected") + return + } + + // Get all animated nodes from the graph + val allNodes = getAllAnimatedNodes(nodesManager) + Log.d(LOG_TAG, "Found ${allNodes.size()} total animated nodes") + + // Log the field names we're using for debugging + Log.d(LOG_TAG, "Using field names: mActiveAnimations, mUpdatedNodes, mAnimatedNodes") + + // Find nodes that are currently animating (have active drivers or are updated) + val animatingNodes = findAnimatingNodes(nodesManager, allNodes) + Log.d(LOG_TAG, "Found ${animatingNodes.size} animating nodes") + + if (animatingNodes.isEmpty()) { + Log.d(LOG_TAG, "No animating nodes found, exiting") + return + } + + // Find all relevant animated nodes (PropsAnimatedNode, StyleAnimatedNode, ValueAnimatedNode) + val relevantNodes = findPropsNodes(animatingNodes, allNodes) + Log.d(LOG_TAG, "Found ${relevantNodes.size} relevant animated nodes") + + if (relevantNodes.isEmpty()) { + Log.d(LOG_TAG, "No relevant animated nodes found, exiting") + return + } + + val viewTags = getViewTags(relevantNodes, allNodes) + Log.d(LOG_TAG, "Found ${viewTags.size} view tags: $viewTags") + + if (viewTags.isEmpty()) { + Log.d(LOG_TAG, "No view tags found, exiting") + return + } + + logViews(reactContext, viewTags) + } catch (e: Exception) { + Log.e(LOG_TAG, "Failed to inquire animating views", e) + } + } + + private fun getNodesManager(reactContext: ReactApplicationContext): NativeAnimatedNodesManager? { + val nativeAnimatedModule = reactContext.getNativeModule(NativeAnimatedModule::class.java) + if (nativeAnimatedModule == null) { + Log.d(LOG_TAG, "NativeAnimatedModule not found") + return null + } + + return try { + // Use the public getNodesManager() method instead of reflection + nativeAnimatedModule.nodesManager + } catch (e: Exception) { + Log.e(LOG_TAG, "Failed to get NativeAnimatedNodesManager via getNodesManager()", e) + null + } + } + + private fun getAllAnimatedNodes(nodesManager: NativeAnimatedNodesManager): SparseArray { + val allNodes = SparseArray() + try { + // Access mAnimatedNodes field using reflection + val animatedNodesField = findOrCacheField(nodesManager.javaClass, "mAnimatedNodes", "mAnimatedNodesField") + @Suppress("UNCHECKED_CAST") + val animatedNodes = animatedNodesField?.get(nodesManager) as? SparseArray + if (animatedNodes != null) { + Log.d(LOG_TAG, "Found ${animatedNodes.size()} animated nodes in graph") + for (i in 0 until animatedNodes.size()) { + val node = animatedNodes.valueAt(i) + allNodes.put(animatedNodes.keyAt(i), node) + } + } else { + Log.w(LOG_TAG, "Could not access mAnimatedNodes field") + } + } catch (e: Exception) { + Log.e(LOG_TAG, "Failed to get all animated nodes", e) + } + return allNodes + } + + private fun findAnimatingNodes(nodesManager: NativeAnimatedNodesManager, allNodes: SparseArray): Set { + val animatingNodes = mutableSetOf() + + try { + // Get nodes from active animations + val activeAnimationsField = findOrCacheField(nodesManager.javaClass, "mActiveAnimations", "mActiveAnimationsField") + @Suppress("UNCHECKED_CAST") + val activeAnimations = activeAnimationsField?.get(nodesManager) as? SparseArray + if (activeAnimations != null) { + Log.d(LOG_TAG, "Found ${activeAnimations.size()} active animations") + for (i in 0 until activeAnimations.size()) { + val driver = activeAnimations.valueAt(i) + Log.d(LOG_TAG, "Active animation driver: ${driver.javaClass.simpleName}") + val animatedValueField = findOrCacheField(driver.javaClass, "animatedValue", "animatedValueField") + val valueNode = animatedValueField?.get(driver) as? AnimatedNode + if (valueNode != null) { + animatingNodes.add(valueNode) + Log.d(LOG_TAG, "Added animating node from active animation: ${valueNode.javaClass.simpleName}") + } + } + } + + // Get nodes from updated nodes + val updatedNodesField = findOrCacheField(nodesManager.javaClass, "mUpdatedNodes", "mUpdatedNodesField") + @Suppress("UNCHECKED_CAST") + val updatedNodes = updatedNodesField?.get(nodesManager) as? SparseArray + if (updatedNodes != null) { + Log.d(LOG_TAG, "Found ${updatedNodes.size()} updated nodes") + for (i in 0 until updatedNodes.size()) { + val node = updatedNodes.valueAt(i) + animatingNodes.add(node) + Log.d(LOG_TAG, "Added updated node: ${node.javaClass.simpleName}") + } + } + } catch (e: Exception) { + Log.e(LOG_TAG, "Failed to find animating nodes", e) + } + + return animatingNodes + } + + private fun findPropsNodes(animatingNodes: Set, allNodes: SparseArray): Set { + val allRelevantNodes = mutableSetOf() + val queue = LinkedList(animatingNodes) + val visited = mutableSetOf() + + while (queue.isNotEmpty()) { + val node = queue.poll() + if (node in visited) { + continue + } + visited.add(node) + + // Check what type of node this is and log accordingly + val nodeType = node.javaClass.simpleName + when (nodeType) { + "PropsAnimatedNode" -> { + allRelevantNodes.add(node) + Log.d(LOG_TAG, "Found PropsAnimatedNode: $nodeType") + } + "StyleAnimatedNode" -> { + allRelevantNodes.add(node) + Log.d(LOG_TAG, "Found StyleAnimatedNode: $nodeType") + } + "ValueAnimatedNode" -> { + allRelevantNodes.add(node) + Log.d(LOG_TAG, "Found ValueAnimatedNode: $nodeType") + } + else -> { + Log.d(LOG_TAG, "Found other animated node: $nodeType") + } + } + + // Traverse children to find more nodes + try { + val childrenField = findOrCacheField(node.javaClass, "children", "childrenField") + @Suppress("UNCHECKED_CAST") + val children = childrenField?.get(node) as? List + if (children != null) { + queue.addAll(children) + } + } catch (e: Exception) { + // Ignored - not all nodes have children + } + } + + return allRelevantNodes + } + + private fun isPropsAnimatedNode(node: AnimatedNode): Boolean { + return try { + // Check if this is actually a PropsAnimatedNode by checking the class name + // and verifying it has the connectedViewTag field + val isPropsNode = node.javaClass.simpleName == "PropsAnimatedNode" + if (isPropsNode) { + val connectedViewTagField = findOrCacheField(node.javaClass, "connectedViewTag", "connectedViewTagField") + connectedViewTagField != null + } else { + false + } + } catch (e: Exception) { + false + } + } + + private fun getViewTags(relevantNodes: Set, allNodes: SparseArray): Set { + val viewTags = mutableSetOf() + for (node in relevantNodes) { + try { + val nodeType = node.javaClass.simpleName + Log.d(LOG_TAG, "Processing $nodeType for view tags") + + when (nodeType) { + "PropsAnimatedNode" -> { + val connectedViewTagField = findOrCacheField(node.javaClass, "connectedViewTag", "connectedViewTagField") + val viewTag = connectedViewTagField?.get(node) as? Int + if (viewTag != null && viewTag != -1) { + viewTags.add(viewTag) + Log.d(LOG_TAG, "PropsAnimatedNode connected to view tag: $viewTag") + + // Log the property mapping to see what properties are being animated + try { + val propNodeMappingField = findOrCacheField(node.javaClass, "propNodeMapping", "propNodeMappingField") + val propNodeMapping = propNodeMappingField?.get(node) as? Map + if (propNodeMapping != null) { + Log.d(LOG_TAG, "View $viewTag has animated properties: ${propNodeMapping.keys}") + } + } catch (e: Exception) { + Log.d(LOG_TAG, "Could not access propNodeMapping for PropsAnimatedNode") + } + } + } + "StyleAnimatedNode" -> { + // StyleAnimatedNode doesn't have connectedViewTag field - it's connected through PropsAnimatedNode + Log.d(LOG_TAG, "StyleAnimatedNode has no direct view connection (connected through PropsAnimatedNode)") + + // Try to access propMapping to see what properties it handles + try { + val propMappingField = findOrCacheField(node.javaClass, "propMapping", "propMappingField") + val propMapping = propMappingField?.get(node) as? Map + if (propMapping != null) { + Log.d(LOG_TAG, "StyleAnimatedNode handles properties: ${propMapping.keys}") + } + } catch (e: Exception) { + Log.d(LOG_TAG, "Could not access StyleAnimatedNode propMapping: ${e.message}") + } + + // Find the connected view by traversing the graph to find PropsAnimatedNode + val connectedViewTag = findConnectedViewThroughGraph(node as AnimatedNode, allNodes) + if (connectedViewTag != -1) { + viewTags.add(connectedViewTag) + Log.d(LOG_TAG, "StyleAnimatedNode connected to view tag through graph: $connectedViewTag") + } else { + Log.w(LOG_TAG, "StyleAnimatedNode has no connected view - this could mean:") + Log.w(LOG_TAG, " 1. No PropsAnimatedNode found in graph traversal") + Log.w(LOG_TAG, " 2. PropsAnimatedNode exists but not connected to any view") + Log.w(LOG_TAG, " 3. Graph traversal failed due to reflection errors") + Log.w(LOG_TAG, " 4. Circular references or disconnected graph") + } + } + "ValueAnimatedNode" -> { + // ValueAnimatedNode typically doesn't have direct view connection + // but we can log its value for debugging + try { + val nodeValueField = findOrCacheField(node.javaClass, "nodeValue", "nodeValueField") + val nodeValue = nodeValueField?.get(node) as? Double + val offsetField = findOrCacheField(node.javaClass, "offset", "offsetField") + val offset = offsetField?.get(node) as? Double + Log.d(LOG_TAG, "ValueAnimatedNode value: $nodeValue, offset: $offset") + } catch (e: Exception) { + Log.d(LOG_TAG, "Could not access ValueAnimatedNode values: ${e.message}") + } + } + else -> { + Log.d(LOG_TAG, "Unknown node type: $nodeType") + } + } + } catch (e: Exception) { + Log.e(LOG_TAG, "Failed to process node: ${node.javaClass.simpleName}", e) + } + } + return viewTags + } + + private fun logViews(reactContext: ReactApplicationContext, viewTags: Set) { + val uiManager = UIManagerHelper.getUIManager(reactContext, UIManagerType.FABRIC) + if (uiManager == null) { + Log.w(LOG_TAG, "Fabric UIManager not found.") + return + } + + for (tag in viewTags) { + try { + reactContext.runOnUiQueueThread { + val view = uiManager.resolveView(tag) + if (view != null) { + ViewLifecycleRegistry.markAnimated(view) + Log.i(LOG_TAG, "Animating view: tag=$tag, class=${view.javaClass.simpleName}, id=${view.id}") + } else { + Log.w(LOG_TAG, "Could not resolve view for tag: $tag") + } + } + } catch (e: Exception) { + Log.e(LOG_TAG, "Failed to resolve or log view for tag: $tag", e) + } + } + } + + private fun findOrCacheField(clazz: Class<*>, fieldName: String, cacheFieldName: String): Field? { + try { + val cacheField = FabricAnimationsInquirer::class.java.getDeclaredField(cacheFieldName).apply { isAccessible = true } + var field = cacheField.get(this) as? Field + if (field == null) { + field = findFieldRecursive(clazz, fieldName) + if (field != null) { + cacheField.set(this, field) + } + } + return field + } catch (e: Exception) { + Log.w(LOG_TAG, "Could not find or cache field $fieldName", e) + return null + } + } + + private fun findFieldRecursive(clazz: Class<*>, fieldName: String): Field? { + var currentClass: Class<*>? = clazz + while (currentClass != null && currentClass != Any::class.java) { + try { + return currentClass.getDeclaredField(fieldName).apply { isAccessible = true } + } catch (e: NoSuchFieldException) { + // Not in this class, check superclass + } + currentClass = currentClass.superclass + } + Log.w(LOG_TAG, "Field '$fieldName' not found in class hierarchy for '${clazz.simpleName}'") + return null + } + + private fun findConnectedViewThroughGraph(startNode: AnimatedNode, allNodes: SparseArray): Int { + val queue = LinkedList() + val visited = mutableSetOf() + var propsNodesFound = 0 + var propsNodesWithView = 0 + var propsNodesWithoutView = 0 + + Log.d(LOG_TAG, "Starting graph traversal from ${startNode.javaClass.simpleName}") + + queue.add(startNode) + visited.add(startNode) + + while (queue.isNotEmpty()) { + val node = queue.poll() + val nodeType = node.javaClass.simpleName + + // Check if this is a PropsAnimatedNode with a connected view + if (nodeType == "PropsAnimatedNode") { + propsNodesFound++ + try { + val connectedViewTagField = findOrCacheField(node.javaClass, "connectedViewTag", "connectedViewTagField") + val viewTag = connectedViewTagField?.get(node) as? Int + if (viewTag != null && viewTag != -1) { + propsNodesWithView++ + Log.d(LOG_TAG, "Found PropsAnimatedNode with connected view: $viewTag") + return viewTag + } else { + propsNodesWithoutView++ + Log.d(LOG_TAG, "Found PropsAnimatedNode but no connected view (viewTag: $viewTag)") + } + } catch (e: Exception) { + Log.w(LOG_TAG, "Failed to access connectedViewTag from PropsAnimatedNode: ${e.message}") + } + } + + // Traverse children to find PropsAnimatedNode + try { + val childrenField = findOrCacheField(node.javaClass, "children", "childrenField") + @Suppress("UNCHECKED_CAST") + val children = childrenField?.get(node) as? List + if (children != null) { + Log.d(LOG_TAG, "Traversing ${children.size} children from $nodeType") + for (child in children) { + if (child !in visited) { + visited.add(child) + queue.add(child) + } + } + } else { + Log.d(LOG_TAG, "$nodeType has no children") + } + } catch (e: Exception) { + Log.d(LOG_TAG, "Could not access children from $nodeType: ${e.message}") + } + } + + Log.w(LOG_TAG, "Graph traversal completed:") + Log.w(LOG_TAG, " - Total nodes visited: ${visited.size}") + Log.w(LOG_TAG, " - PropsAnimatedNodes found: $propsNodesFound") + Log.w(LOG_TAG, " - PropsAnimatedNodes with view: $propsNodesWithView") + Log.w(LOG_TAG, " - PropsAnimatedNodes without view: $propsNodesWithoutView") + + return -1 + } +} diff --git a/detox/android/detox/src/full/java/com/wix/detox/inquiry/ViewLifecycleRegistry.kt b/detox/android/detox/src/full/java/com/wix/detox/inquiry/ViewLifecycleRegistry.kt new file mode 100644 index 0000000000..fe9a4ad4f3 --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/inquiry/ViewLifecycleRegistry.kt @@ -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() + private val updatedViews = ConcurrentHashMap() + private val animatedViews = ConcurrentHashMap() + private val customEvents = ConcurrentHashMap>>() + + /** + * 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 = 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> +) { + /** + * 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 } + } +} diff --git a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/animations/AnimatedModuleIdlingResource.kt b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/animations/AnimatedModuleIdlingResource.kt index 105ee23fd6..74086367e6 100644 --- a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/animations/AnimatedModuleIdlingResource.kt +++ b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/animations/AnimatedModuleIdlingResource.kt @@ -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 @@ -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 } @@ -115,7 +120,7 @@ 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) { @@ -123,7 +128,7 @@ class OperationsQueueReflected(private val operationsQueue: Any) { @Suppress("UNCHECKED_CAST") return (isEmptyProperty as KProperty1).get(operationsQueue) as Boolean } - + throw DetoxErrors.DetoxIllegalStateException("isEmpty method/property cannot be reached") } } From f035f77241f92710733bfd721acb99ffce6b5f5a Mon Sep 17 00:00:00 2001 From: Yaroslav Serhieiev Date: Thu, 9 Oct 2025 19:41:25 +0300 Subject: [PATCH 4/8] temp: add inquiry --- .../wix/detox/inquiry/FabricAnimationsInquirer.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/detox/android/detox/src/full/java/com/wix/detox/inquiry/FabricAnimationsInquirer.kt b/detox/android/detox/src/full/java/com/wix/detox/inquiry/FabricAnimationsInquirer.kt index 5ea8a15488..0624c404ed 100644 --- a/detox/android/detox/src/full/java/com/wix/detox/inquiry/FabricAnimationsInquirer.kt +++ b/detox/android/detox/src/full/java/com/wix/detox/inquiry/FabricAnimationsInquirer.kt @@ -321,7 +321,17 @@ object FabricAnimationsInquirer { val view = uiManager.resolveView(tag) if (view != null) { ViewLifecycleRegistry.markAnimated(view) - Log.i(LOG_TAG, "Animating view: tag=$tag, class=${view.javaClass.simpleName}, id=${view.id}") + + // Get view coordinates and dimensions + val left = view.left + val top = view.top + val right = view.right + val bottom = view.bottom + val width = right - left + val height = bottom - top + + Log.i(LOG_TAG, "Animating view: tag=$tag, class=${view.javaClass.simpleName}, id=${view.id}, " + + "bounds=[$left,$top,$right,$bottom], size=${width}x${height}") } else { Log.w(LOG_TAG, "Could not resolve view for tag: $tag") } From b90be7a215c4090a82990a1da11e86082c992f54 Mon Sep 17 00:00:00 2001 From: Yaroslav Serhieiev Date: Thu, 9 Oct 2025 20:28:09 +0300 Subject: [PATCH 5/8] Publish 20.44.0-smoke.2 [ci skip] --- detox/package.json | 2 +- detox/test/package.json | 4 ++-- examples/demo-native-android/package.json | 4 ++-- examples/demo-native-ios/package.json | 4 ++-- examples/demo-plugin/package.json | 4 ++-- examples/demo-react-native-detox-instruments/package.json | 4 ++-- examples/demo-react-native/package.json | 4 ++-- lerna.json | 2 +- package.json | 2 +- 9 files changed, 15 insertions(+), 15 deletions(-) diff --git a/detox/package.json b/detox/package.json index 3d5a38dc64..6505d4f99b 100644 --- a/detox/package.json +++ b/detox/package.json @@ -1,7 +1,7 @@ { "name": "detox", "description": "E2E tests and automation for mobile", - "version": "20.44.0-smoke.1", + "version": "20.44.0-smoke.2", "bin": { "detox": "local-cli/cli.js" }, diff --git a/detox/test/package.json b/detox/test/package.json index 502279b7bd..bae92a4eac 100644 --- a/detox/test/package.json +++ b/detox/test/package.json @@ -1,6 +1,6 @@ { "name": "detox-test", - "version": "20.44.0-smoke.1", + "version": "20.44.0-smoke.2", "private": true, "engines": { "node": ">=18" @@ -67,7 +67,7 @@ "@typescript-eslint/parser": "^6.16.0", "axios": "^1.7.7", "cross-env": "^7.0.3", - "detox": "^20.44.0-smoke.1", + "detox": "^20.44.0-smoke.2", "detox-allure2-adapter": "1.0.0-alpha.34", "eslint": "^8.56.0", "eslint-plugin-unicorn": "^50.0.1", diff --git a/examples/demo-native-android/package.json b/examples/demo-native-android/package.json index a033265e70..6c6ab1d167 100644 --- a/examples/demo-native-android/package.json +++ b/examples/demo-native-android/package.json @@ -1,13 +1,13 @@ { "name": "detox-demo-native-android", - "version": "20.44.0-smoke.1", + "version": "20.44.0-smoke.2", "private": true, "scripts": { "packager": "react-native start", "detox-server": "detox run-server" }, "devDependencies": { - "detox": "^20.44.0-smoke.1" + "detox": "^20.44.0-smoke.2" }, "detox": {} } diff --git a/examples/demo-native-ios/package.json b/examples/demo-native-ios/package.json index e70f860644..c3a1af39a0 100644 --- a/examples/demo-native-ios/package.json +++ b/examples/demo-native-ios/package.json @@ -1,9 +1,9 @@ { "name": "detox-demo-native-ios", - "version": "20.44.0-smoke.1", + "version": "20.44.0-smoke.2", "private": true, "devDependencies": { - "detox": "^20.44.0-smoke.1" + "detox": "^20.44.0-smoke.2" }, "detox": { "specs": "", diff --git a/examples/demo-plugin/package.json b/examples/demo-plugin/package.json index 3b094dc530..82ce7de006 100644 --- a/examples/demo-plugin/package.json +++ b/examples/demo-plugin/package.json @@ -1,12 +1,12 @@ { "name": "demo-plugin", - "version": "20.44.0-smoke.1", + "version": "20.44.0-smoke.2", "private": true, "scripts": { "test:plugin": "detox test --configuration plugin -l verbose" }, "devDependencies": { - "detox": "^20.44.0-smoke.1", + "detox": "^20.44.0-smoke.2", "jest": "^30.0.3" }, "detox": { diff --git a/examples/demo-react-native-detox-instruments/package.json b/examples/demo-react-native-detox-instruments/package.json index f2acc33b32..31477e8b6e 100644 --- a/examples/demo-react-native-detox-instruments/package.json +++ b/examples/demo-react-native-detox-instruments/package.json @@ -1,10 +1,10 @@ { "name": "demo-react-native-detox-instruments", - "version": "20.44.0-smoke.1", + "version": "20.44.0-smoke.2", "private": true, "scripts": {}, "devDependencies": { - "detox": "^20.44.0-smoke.1" + "detox": "^20.44.0-smoke.2" }, "detox": { "configurations": { diff --git a/examples/demo-react-native/package.json b/examples/demo-react-native/package.json index 50558f8f08..583f583b84 100644 --- a/examples/demo-react-native/package.json +++ b/examples/demo-react-native/package.json @@ -1,6 +1,6 @@ { "name": "example", - "version": "20.44.0-smoke.1", + "version": "20.44.0-smoke.2", "private": true, "scripts": { "start": "react-native start", @@ -41,7 +41,7 @@ "@types/jest": "^29.2.1", "@types/react": "^19.1.0", "@types/react-test-renderer": "^19.1.0", - "detox": "^20.44.0-smoke.1", + "detox": "^20.44.0-smoke.2", "fs-extra": "^9.1.0", "jest": "^30.0.3", "react-test-renderer": "19.1.0", diff --git a/lerna.json b/lerna.json index 45e2e9c566..01d3cf254a 100644 --- a/lerna.json +++ b/lerna.json @@ -14,7 +14,7 @@ "generation", "." ], - "version": "20.44.0-smoke.1", + "version": "20.44.0-smoke.2", "npmClient": "npm", "command": { "publish": { diff --git a/package.json b/package.json index 2b31f4ee54..9d72f2ffc6 100644 --- a/package.json +++ b/package.json @@ -55,5 +55,5 @@ "shell-utils": "1.x.x", "unified": "^10.1.0" }, - "version": "20.44.0-smoke.1" + "version": "20.44.0-smoke.2" } From 69e5e1f5fadd16c25e4303c7bda877f556527c5e Mon Sep 17 00:00:00 2001 From: mobileoss Date: Thu, 9 Oct 2025 17:49:16 +0000 Subject: [PATCH 6/8] Publish 20.44.0-smoke.3 [ci skip] --- detox/package.json | 2 +- detox/test/package.json | 4 ++-- examples/demo-native-android/package.json | 4 ++-- examples/demo-native-ios/package.json | 4 ++-- examples/demo-plugin/package.json | 4 ++-- examples/demo-react-native-detox-instruments/package.json | 4 ++-- examples/demo-react-native/package.json | 4 ++-- lerna.json | 2 +- package.json | 2 +- 9 files changed, 15 insertions(+), 15 deletions(-) diff --git a/detox/package.json b/detox/package.json index 6505d4f99b..ee2661a912 100644 --- a/detox/package.json +++ b/detox/package.json @@ -1,7 +1,7 @@ { "name": "detox", "description": "E2E tests and automation for mobile", - "version": "20.44.0-smoke.2", + "version": "20.44.0-smoke.3", "bin": { "detox": "local-cli/cli.js" }, diff --git a/detox/test/package.json b/detox/test/package.json index bae92a4eac..a1f20cefeb 100644 --- a/detox/test/package.json +++ b/detox/test/package.json @@ -1,6 +1,6 @@ { "name": "detox-test", - "version": "20.44.0-smoke.2", + "version": "20.44.0-smoke.3", "private": true, "engines": { "node": ">=18" @@ -67,7 +67,7 @@ "@typescript-eslint/parser": "^6.16.0", "axios": "^1.7.7", "cross-env": "^7.0.3", - "detox": "^20.44.0-smoke.2", + "detox": "^20.44.0-smoke.3", "detox-allure2-adapter": "1.0.0-alpha.34", "eslint": "^8.56.0", "eslint-plugin-unicorn": "^50.0.1", diff --git a/examples/demo-native-android/package.json b/examples/demo-native-android/package.json index 6c6ab1d167..24b1a5c15b 100644 --- a/examples/demo-native-android/package.json +++ b/examples/demo-native-android/package.json @@ -1,13 +1,13 @@ { "name": "detox-demo-native-android", - "version": "20.44.0-smoke.2", + "version": "20.44.0-smoke.3", "private": true, "scripts": { "packager": "react-native start", "detox-server": "detox run-server" }, "devDependencies": { - "detox": "^20.44.0-smoke.2" + "detox": "^20.44.0-smoke.3" }, "detox": {} } diff --git a/examples/demo-native-ios/package.json b/examples/demo-native-ios/package.json index c3a1af39a0..ec8bf03864 100644 --- a/examples/demo-native-ios/package.json +++ b/examples/demo-native-ios/package.json @@ -1,9 +1,9 @@ { "name": "detox-demo-native-ios", - "version": "20.44.0-smoke.2", + "version": "20.44.0-smoke.3", "private": true, "devDependencies": { - "detox": "^20.44.0-smoke.2" + "detox": "^20.44.0-smoke.3" }, "detox": { "specs": "", diff --git a/examples/demo-plugin/package.json b/examples/demo-plugin/package.json index 82ce7de006..fe4119fbbc 100644 --- a/examples/demo-plugin/package.json +++ b/examples/demo-plugin/package.json @@ -1,12 +1,12 @@ { "name": "demo-plugin", - "version": "20.44.0-smoke.2", + "version": "20.44.0-smoke.3", "private": true, "scripts": { "test:plugin": "detox test --configuration plugin -l verbose" }, "devDependencies": { - "detox": "^20.44.0-smoke.2", + "detox": "^20.44.0-smoke.3", "jest": "^30.0.3" }, "detox": { diff --git a/examples/demo-react-native-detox-instruments/package.json b/examples/demo-react-native-detox-instruments/package.json index 31477e8b6e..6cfd777274 100644 --- a/examples/demo-react-native-detox-instruments/package.json +++ b/examples/demo-react-native-detox-instruments/package.json @@ -1,10 +1,10 @@ { "name": "demo-react-native-detox-instruments", - "version": "20.44.0-smoke.2", + "version": "20.44.0-smoke.3", "private": true, "scripts": {}, "devDependencies": { - "detox": "^20.44.0-smoke.2" + "detox": "^20.44.0-smoke.3" }, "detox": { "configurations": { diff --git a/examples/demo-react-native/package.json b/examples/demo-react-native/package.json index 583f583b84..a68a64a40e 100644 --- a/examples/demo-react-native/package.json +++ b/examples/demo-react-native/package.json @@ -1,6 +1,6 @@ { "name": "example", - "version": "20.44.0-smoke.2", + "version": "20.44.0-smoke.3", "private": true, "scripts": { "start": "react-native start", @@ -41,7 +41,7 @@ "@types/jest": "^29.2.1", "@types/react": "^19.1.0", "@types/react-test-renderer": "^19.1.0", - "detox": "^20.44.0-smoke.2", + "detox": "^20.44.0-smoke.3", "fs-extra": "^9.1.0", "jest": "^30.0.3", "react-test-renderer": "19.1.0", diff --git a/lerna.json b/lerna.json index 01d3cf254a..56e12eda09 100644 --- a/lerna.json +++ b/lerna.json @@ -14,7 +14,7 @@ "generation", "." ], - "version": "20.44.0-smoke.2", + "version": "20.44.0-smoke.3", "npmClient": "npm", "command": { "publish": { diff --git a/package.json b/package.json index 9d72f2ffc6..3dab3a20a4 100644 --- a/package.json +++ b/package.json @@ -55,5 +55,5 @@ "shell-utils": "1.x.x", "unified": "^10.1.0" }, - "version": "20.44.0-smoke.2" + "version": "20.44.0-smoke.3" } From dec78d4c9fb7306d1015c705e11a120413e06cb5 Mon Sep 17 00:00:00 2001 From: Yaroslav Serhieiev Date: Wed, 15 Oct 2025 15:30:06 +0300 Subject: [PATCH 7/8] fix: add queued animations introspection --- .../detox/inquiry/FabricAnimationsInquirer.kt | 203 +++++++++++++++++- 1 file changed, 196 insertions(+), 7 deletions(-) diff --git a/detox/android/detox/src/full/java/com/wix/detox/inquiry/FabricAnimationsInquirer.kt b/detox/android/detox/src/full/java/com/wix/detox/inquiry/FabricAnimationsInquirer.kt index 0624c404ed..1aacba28ba 100644 --- a/detox/android/detox/src/full/java/com/wix/detox/inquiry/FabricAnimationsInquirer.kt +++ b/detox/android/detox/src/full/java/com/wix/detox/inquiry/FabricAnimationsInquirer.kt @@ -27,6 +27,10 @@ object FabricAnimationsInquirer { private var propMappingField: Field? = null private var nodeValueField: Field? = null private var offsetField: Field? = null + private var preOperationsField: Field? = null + private var operationsField: Field? = null + private var mQueueField: Field? = null + private var mPeekedOperationField: Field? = null fun logAnimatingViews(reactContext: ReactApplicationContext) { try { @@ -38,15 +42,35 @@ object FabricAnimationsInquirer { val nodesManager = getNodesManager(reactContext) ?: return Log.d(LOG_TAG, "Got nodesManager: ${nodesManager.javaClass.simpleName}") - // Check if there are any active animations first + // Check for both queued and active animations + val animatedModule = reactContext.getNativeModule(NativeAnimatedModule::class.java) + if (animatedModule == null) { + Log.d(LOG_TAG, "NativeAnimatedModule not found") + return + } + + // Check queued animations first + val hasQueued = checkQueuedAnimations(animatedModule) + Log.d(LOG_TAG, "hasQueuedAnimations() returned: $hasQueued") + + // Check active animations val hasActive = nodesManager.hasActiveAnimations() Log.d(LOG_TAG, "hasActiveAnimations() returned: $hasActive") - if (!hasActive) { - Log.d(LOG_TAG, "No active animations detected") + if (!hasQueued && !hasActive) { + Log.d(LOG_TAG, "No queued or active animations detected") return } + if (hasQueued) { + Log.d(LOG_TAG, "Found queued animations - analyzing operations queue") + val queuedViewTags = analyzeQueuedOperations(animatedModule) + if (queuedViewTags.isNotEmpty()) { + Log.d(LOG_TAG, "Found ${queuedViewTags.size} views with queued animations: $queuedViewTags") + logViews(reactContext, queuedViewTags) + } + } + // Get all animated nodes from the graph val allNodes = getAllAnimatedNodes(nodesManager) Log.d(LOG_TAG, "Found ${allNodes.size()} total animated nodes") @@ -171,7 +195,7 @@ object FabricAnimationsInquirer { val visited = mutableSetOf() while (queue.isNotEmpty()) { - val node = queue.poll() + val node = queue.poll() ?: continue if (node in visited) { continue } @@ -181,15 +205,15 @@ object FabricAnimationsInquirer { val nodeType = node.javaClass.simpleName when (nodeType) { "PropsAnimatedNode" -> { - allRelevantNodes.add(node) + allRelevantNodes.add(node as Any) Log.d(LOG_TAG, "Found PropsAnimatedNode: $nodeType") } "StyleAnimatedNode" -> { - allRelevantNodes.add(node) + allRelevantNodes.add(node as Any) Log.d(LOG_TAG, "Found StyleAnimatedNode: $nodeType") } "ValueAnimatedNode" -> { - allRelevantNodes.add(node) + allRelevantNodes.add(node as Any) Log.d(LOG_TAG, "Found ValueAnimatedNode: $nodeType") } else -> { @@ -247,6 +271,7 @@ object FabricAnimationsInquirer { // Log the property mapping to see what properties are being animated try { val propNodeMappingField = findOrCacheField(node.javaClass, "propNodeMapping", "propNodeMappingField") + @Suppress("UNCHECKED_CAST") val propNodeMapping = propNodeMappingField?.get(node) as? Map if (propNodeMapping != null) { Log.d(LOG_TAG, "View $viewTag has animated properties: ${propNodeMapping.keys}") @@ -263,6 +288,7 @@ object FabricAnimationsInquirer { // Try to access propMapping to see what properties it handles try { val propMappingField = findOrCacheField(node.javaClass, "propMapping", "propMappingField") + @Suppress("UNCHECKED_CAST") val propMapping = propMappingField?.get(node) as? Map if (propMapping != null) { Log.d(LOG_TAG, "StyleAnimatedNode handles properties: ${propMapping.keys}") @@ -437,4 +463,167 @@ object FabricAnimationsInquirer { return -1 } + + private fun checkQueuedAnimations(animatedModule: NativeAnimatedModule): Boolean { + return try { + // Check mPreOperations queue (Android field name) + val preOperationsField = findOrCacheField(animatedModule.javaClass, "mPreOperations", "preOperationsField") + val preOperations = preOperationsField?.get(animatedModule) + val preOperationsEmpty = checkOperationsQueueEmpty(preOperations) + Log.d(LOG_TAG, "mPreOperations queue empty: $preOperationsEmpty") + + // Check mOperations queue (Android field name) + val operationsField = findOrCacheField(animatedModule.javaClass, "mOperations", "operationsField") + val operations = operationsField?.get(animatedModule) + val operationsEmpty = checkOperationsQueueEmpty(operations) + Log.d(LOG_TAG, "mOperations queue empty: $operationsEmpty") + + val hasQueued = !preOperationsEmpty || !operationsEmpty + Log.d(LOG_TAG, "Has queued animations: $hasQueued") + hasQueued + } catch (e: Exception) { + Log.e(LOG_TAG, "Failed to check queued animations", e) + false + } + } + + private fun checkOperationsQueueEmpty(operationsQueue: Any?): Boolean { + if (operationsQueue == null) { + Log.d(LOG_TAG, "Operations queue is null") + return true + } + + return try { + // Try to get isEmpty method + val isEmptyMethod = operationsQueue.javaClass.getDeclaredMethod("isEmpty") + isEmptyMethod.isAccessible = true + val isEmpty = isEmptyMethod.invoke(operationsQueue) as Boolean + Log.d(LOG_TAG, "Operations queue isEmpty() result: $isEmpty") + isEmpty + } catch (e: Exception) { + Log.d(LOG_TAG, "Could not call isEmpty() on operations queue, trying property access") + try { + // Fallback to property access + val isEmptyProperty = operationsQueue.javaClass.getDeclaredField("isEmpty") + isEmptyProperty.isAccessible = true + val isEmpty = isEmptyProperty.get(operationsQueue) as Boolean + Log.d(LOG_TAG, "Operations queue isEmpty property: $isEmpty") + isEmpty + } catch (e2: Exception) { + Log.e(LOG_TAG, "Could not access isEmpty property on operations queue", e2) + true // Assume empty if we can't determine + } + } + } + + private fun analyzeQueuedOperations(animatedModule: NativeAnimatedModule): Set { + val viewTags = mutableSetOf() + try { + Log.d(LOG_TAG, "Analyzing queued operations...") + + // Analyze mPreOperations queue (Android field name) + val preOperationsField = findOrCacheField(animatedModule.javaClass, "mPreOperations", "preOperationsField") + val preOperations = preOperationsField?.get(animatedModule) + if (preOperations != null) { + Log.d(LOG_TAG, "Analyzing mPreOperations queue...") + val preOpsViewTags = analyzeOperationsQueue(preOperations, "mPreOperations") + viewTags.addAll(preOpsViewTags) + } + + // Analyze mOperations queue (Android field name) + val operationsField = findOrCacheField(animatedModule.javaClass, "mOperations", "operationsField") + val operations = operationsField?.get(animatedModule) + if (operations != null) { + Log.d(LOG_TAG, "Analyzing mOperations queue...") + val opsViewTags = analyzeOperationsQueue(operations, "mOperations") + viewTags.addAll(opsViewTags) + } + } catch (e: Exception) { + Log.e(LOG_TAG, "Failed to analyze queued operations", e) + } + return viewTags + } + + private fun analyzeOperationsQueue(operationsQueue: Any, queueName: String): Set { + val viewTags = mutableSetOf() + try { + // Log queue class information first + Log.d(LOG_TAG, "$queueName queue class: ${operationsQueue.javaClass.simpleName}") + Log.d(LOG_TAG, "$queueName queue fields: ${operationsQueue.javaClass.declaredFields.map { it.name }}") + + // Try to access the internal mQueue field (ConcurrentLinkedQueue) + val mQueueField = findOrCacheField(operationsQueue.javaClass, "mQueue", "mQueueField") + if (mQueueField != null) { + val mQueue = mQueueField.get(operationsQueue) + Log.d(LOG_TAG, "Found mQueue in $queueName: $mQueue") + + // Try to get queue size + try { + val sizeMethod = mQueue.javaClass.getDeclaredMethod("size") + sizeMethod.isAccessible = true + val size = sizeMethod.invoke(mQueue) as Int + Log.d(LOG_TAG, "$queueName mQueue size: $size") + } catch (e: Exception) { + Log.d(LOG_TAG, "Could not get size of $queueName mQueue") + } + + // Try to peek at queue contents (without removing) + try { + val peekMethod = mQueue.javaClass.getDeclaredMethod("peek") + peekMethod.isAccessible = true + val peekedOperation = peekMethod.invoke(mQueue) + if (peekedOperation != null) { + Log.d(LOG_TAG, "Peeked operation from $queueName: ${peekedOperation.javaClass.simpleName}") + + // Try to extract view tags from the operation + val operationViewTags = extractViewTagsFromOperation(peekedOperation) + viewTags.addAll(operationViewTags) + } + } catch (e: Exception) { + Log.d(LOG_TAG, "Could not peek at $queueName queue contents: ${e.message}") + } + } + + // Try to access mPeekedOperation field + val mPeekedOperationField = findOrCacheField(operationsQueue.javaClass, "mPeekedOperation", "mPeekedOperationField") + if (mPeekedOperationField != null) { + val mPeekedOperation = mPeekedOperationField.get(operationsQueue) + if (mPeekedOperation != null) { + Log.d(LOG_TAG, "Found mPeekedOperation in $queueName: ${mPeekedOperation.javaClass.simpleName}") + + // Try to extract view tags from the peeked operation + val peekedViewTags = extractViewTagsFromOperation(mPeekedOperation) + viewTags.addAll(peekedViewTags) + } + } + + } catch (e: Exception) { + Log.e(LOG_TAG, "Failed to analyze $queueName queue", e) + } + return viewTags + } + + private fun extractViewTagsFromOperation(operation: Any): Set { + val viewTags = mutableSetOf() + try { + Log.d(LOG_TAG, "Analyzing operation: ${operation.javaClass.simpleName}") + + // Try to find view tags in the operation's fields + val fields = operation.javaClass.declaredFields + for (field in fields) { + field.isAccessible = true + val value = field.get(operation) + Log.d(LOG_TAG, "Operation field ${field.name}: $value (${value?.javaClass?.simpleName})") + + // Look for integer fields that might be view tags + if (value is Int && value > 0) { + Log.d(LOG_TAG, "Found potential view tag: $value") + viewTags.add(value) + } + } + } catch (e: Exception) { + Log.d(LOG_TAG, "Failed to extract view tags from operation: ${e.message}") + } + return viewTags + } } From 35e4dca0f8f18ac2e348331d1ae7519f37e237a4 Mon Sep 17 00:00:00 2001 From: mobileoss Date: Wed, 15 Oct 2025 12:39:21 +0000 Subject: [PATCH 8/8] Publish 20.44.0-smoke.4 [ci skip] --- detox/package.json | 2 +- detox/test/package.json | 4 ++-- examples/demo-native-android/package.json | 4 ++-- examples/demo-native-ios/package.json | 4 ++-- examples/demo-plugin/package.json | 4 ++-- examples/demo-react-native-detox-instruments/package.json | 4 ++-- examples/demo-react-native/package.json | 4 ++-- lerna.json | 2 +- package.json | 2 +- 9 files changed, 15 insertions(+), 15 deletions(-) diff --git a/detox/package.json b/detox/package.json index ee2661a912..d00d27d536 100644 --- a/detox/package.json +++ b/detox/package.json @@ -1,7 +1,7 @@ { "name": "detox", "description": "E2E tests and automation for mobile", - "version": "20.44.0-smoke.3", + "version": "20.44.0-smoke.4", "bin": { "detox": "local-cli/cli.js" }, diff --git a/detox/test/package.json b/detox/test/package.json index a1f20cefeb..8c890ebd20 100644 --- a/detox/test/package.json +++ b/detox/test/package.json @@ -1,6 +1,6 @@ { "name": "detox-test", - "version": "20.44.0-smoke.3", + "version": "20.44.0-smoke.4", "private": true, "engines": { "node": ">=18" @@ -67,7 +67,7 @@ "@typescript-eslint/parser": "^6.16.0", "axios": "^1.7.7", "cross-env": "^7.0.3", - "detox": "^20.44.0-smoke.3", + "detox": "^20.44.0-smoke.4", "detox-allure2-adapter": "1.0.0-alpha.34", "eslint": "^8.56.0", "eslint-plugin-unicorn": "^50.0.1", diff --git a/examples/demo-native-android/package.json b/examples/demo-native-android/package.json index 24b1a5c15b..6d5524abbe 100644 --- a/examples/demo-native-android/package.json +++ b/examples/demo-native-android/package.json @@ -1,13 +1,13 @@ { "name": "detox-demo-native-android", - "version": "20.44.0-smoke.3", + "version": "20.44.0-smoke.4", "private": true, "scripts": { "packager": "react-native start", "detox-server": "detox run-server" }, "devDependencies": { - "detox": "^20.44.0-smoke.3" + "detox": "^20.44.0-smoke.4" }, "detox": {} } diff --git a/examples/demo-native-ios/package.json b/examples/demo-native-ios/package.json index ec8bf03864..a7e015cb29 100644 --- a/examples/demo-native-ios/package.json +++ b/examples/demo-native-ios/package.json @@ -1,9 +1,9 @@ { "name": "detox-demo-native-ios", - "version": "20.44.0-smoke.3", + "version": "20.44.0-smoke.4", "private": true, "devDependencies": { - "detox": "^20.44.0-smoke.3" + "detox": "^20.44.0-smoke.4" }, "detox": { "specs": "", diff --git a/examples/demo-plugin/package.json b/examples/demo-plugin/package.json index fe4119fbbc..54ba995f14 100644 --- a/examples/demo-plugin/package.json +++ b/examples/demo-plugin/package.json @@ -1,12 +1,12 @@ { "name": "demo-plugin", - "version": "20.44.0-smoke.3", + "version": "20.44.0-smoke.4", "private": true, "scripts": { "test:plugin": "detox test --configuration plugin -l verbose" }, "devDependencies": { - "detox": "^20.44.0-smoke.3", + "detox": "^20.44.0-smoke.4", "jest": "^30.0.3" }, "detox": { diff --git a/examples/demo-react-native-detox-instruments/package.json b/examples/demo-react-native-detox-instruments/package.json index 6cfd777274..71fcb6a41b 100644 --- a/examples/demo-react-native-detox-instruments/package.json +++ b/examples/demo-react-native-detox-instruments/package.json @@ -1,10 +1,10 @@ { "name": "demo-react-native-detox-instruments", - "version": "20.44.0-smoke.3", + "version": "20.44.0-smoke.4", "private": true, "scripts": {}, "devDependencies": { - "detox": "^20.44.0-smoke.3" + "detox": "^20.44.0-smoke.4" }, "detox": { "configurations": { diff --git a/examples/demo-react-native/package.json b/examples/demo-react-native/package.json index a68a64a40e..adedebf0ec 100644 --- a/examples/demo-react-native/package.json +++ b/examples/demo-react-native/package.json @@ -1,6 +1,6 @@ { "name": "example", - "version": "20.44.0-smoke.3", + "version": "20.44.0-smoke.4", "private": true, "scripts": { "start": "react-native start", @@ -41,7 +41,7 @@ "@types/jest": "^29.2.1", "@types/react": "^19.1.0", "@types/react-test-renderer": "^19.1.0", - "detox": "^20.44.0-smoke.3", + "detox": "^20.44.0-smoke.4", "fs-extra": "^9.1.0", "jest": "^30.0.3", "react-test-renderer": "19.1.0", diff --git a/lerna.json b/lerna.json index 56e12eda09..93ecdfadbe 100644 --- a/lerna.json +++ b/lerna.json @@ -14,7 +14,7 @@ "generation", "." ], - "version": "20.44.0-smoke.3", + "version": "20.44.0-smoke.4", "npmClient": "npm", "command": { "publish": { diff --git a/package.json b/package.json index 3dab3a20a4..22fd8eb6a2 100644 --- a/package.json +++ b/package.json @@ -55,5 +55,5 @@ "shell-utils": "1.x.x", "unified": "^10.1.0" }, - "version": "20.44.0-smoke.3" + "version": "20.44.0-smoke.4" }