Skip to content

Commit

Permalink
Adding full support for testID with uiautomator.
Browse files Browse the repository at this point in the history
* Calling view.setId() with the matching resource-id of an id found in R.class.  Added TestIdUtil to facilitate this.
* Updating the android sample project to include a testID example.  Updating the e2e test to use it.
* Changing the signature for virtually all Event Classes to require the View instead of the viewTag.  This reduces the number of locations where TestIdUtil.getOriginalReactTag is called.
* Minimizing the impact in non __DEV__ environments where testID should not be set by simply returning view.getId() in TestIdUtil.getOriginalReactTag.
* This closes facebook#9777.
  • Loading branch information
jsdevel committed Oct 26, 2016
1 parent 82911a8 commit 1a69e13
Show file tree
Hide file tree
Showing 56 changed files with 934 additions and 129 deletions.
29 changes: 29 additions & 0 deletions Libraries/Components/View/View.js
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,35 @@ const View = React.createClass({
* Used to locate this view in end-to-end tests.
*
* > This disables the 'layout-only view removal' optimization for this view!
*
* ### Android Specifics
*
* By default this value is passed to the underlying View's
* [setTag](https://developer.android.com/reference/android/view/View.html#setTag(java.lang.Object)
* method. Very few testing frameworks support looking up
* a View by tag, let alone `uiautomatorviewer` which only supports `content-description`, `resource-id`, `text`, and `XPath`.
*
* While React Native does *not* utilize XML based layouts for android Views it
* is still possible to add [android:id](https://developer.android.com/reference/android/view/View.html#attr_android:id)
* to the underlying View in order to support
* [findViewById](https://developer.android.com/reference/android/app/Activity.html#findViewById(int)).
*
* This is achieved by:
*
* 1. Defining a resource id in your android project's `res` folder (typically at
* `./android/app/src/main/res/values/ids.xml`).
*
* 2. Adding your resource ids to `ids.xml` E.G.
*
* ```xml
* <?xml version="1.0" encoding="utf-8"?>
* <resources>
* <item name="something" type="id"/>
* </resources>
*
* ```
* 3. Using the resource id as `testID` E.G. `<View testID="something">`.
*
*/
testID: PropTypes.string,

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,15 +114,15 @@ public void testMetionsInputColors() throws Throwable {

eventDispatcher.dispatchEvent(
new ReactTextChangedEvent(
reactEditText.getId(),
reactEditText,
newText.toString(),
(int) PixelUtil.toDIPFromPixel(contentWidth),
(int) PixelUtil.toDIPFromPixel(contentHeight),
reactEditText.incrementAndGetEventCounter()));

eventDispatcher.dispatchEvent(
new ReactTextInputEvent(
reactEditText.getId(),
reactEditText,
newText.toString(),
"",
start,
Expand All @@ -146,15 +146,15 @@ public void testMetionsInputColors() throws Throwable {

eventDispatcher.dispatchEvent(
new ReactTextChangedEvent(
reactEditText.getId(),
reactEditText,
newText.toString(),
(int) PixelUtil.toDIPFromPixel(contentWidth),
(int) PixelUtil.toDIPFromPixel(contentHeight),
reactEditText.incrementAndGetEventCounter()));

eventDispatcher.dispatchEvent(
new ReactTextInputEvent(
reactEditText.getId(),
reactEditText,
moreText,
"",
start,
Expand All @@ -178,15 +178,15 @@ public void testMetionsInputColors() throws Throwable {

eventDispatcher.dispatchEvent(
new ReactTextChangedEvent(
reactEditText.getId(),
reactEditText,
newText.toString(),
(int) PixelUtil.toDIPFromPixel(contentWidth),
(int) PixelUtil.toDIPFromPixel(contentHeight),
reactEditText.incrementAndGetEventCounter()));

eventDispatcher.dispatchEvent(
new ReactTextInputEvent(
reactEditText.getId(),
reactEditText,
moreText,
"",
start,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
import static com.facebook.react.bridge.ReactMarkerConstants.PROCESS_PACKAGES_START;
import static com.facebook.react.bridge.ReactMarkerConstants.SETUP_REACT_CONTEXT_END;
import static com.facebook.react.bridge.ReactMarkerConstants.SETUP_REACT_CONTEXT_START;
import static com.facebook.react.common.TestIdUtil.getOriginalReactTag;
import static com.facebook.systrace.Systrace.TRACE_TAG_REACT_JAVA_BRIDGE;

/**
Expand Down Expand Up @@ -818,7 +819,7 @@ private void detachViewFromInstance(
CatalystInstance catalystInstance) {
UiThreadUtil.assertOnUiThread();
catalystInstance.getJSModule(AppRegistry.class)
.unmountApplicationComponentAtRootTag(rootView.getId());
.unmountApplicationComponentAtRootTag(getOriginalReactTag(rootView));
}

private void tearDownReactContext(ReactContext reactContext) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package com.facebook.react.common;

import android.view.View;

import com.facebook.react.common.annotations.VisibleForTesting;

import java.util.concurrent.ConcurrentHashMap;

/**
* Utility methods for managing testIDs on views and mapping them back to React Tags.
*/
public class TestIdUtil {
private static final ConcurrentHashMap<String, Integer> TEST_IDS = new ConcurrentHashMap<>();
private static final ConcurrentHashMap<Integer, Integer> ORIGINAL_REACT_TAGS = new ConcurrentHashMap<>();
private static volatile boolean hasTagMappings;

/**
* Looks for defined resource IDs in R.class by the name of testId and if a matching resource ID is
* found it is passed to the view's setId method. Before the view's Id is overridden it is stored
* in an internal association with the view's identity hash code for later retrieval
* (see {@link #getOriginalReactTag(View)}). {@link View#addOn}
*
* @param view
* @param testId
* @param <T>
*/
public static <T extends View> void setTestId(T view, String testId) {
int mappedTestId;
if (!TEST_IDS.containsKey(testId)) {
mappedTestId = view.getResources().getIdentifier(testId, "id", view.getContext().getPackageName());
TEST_IDS.put(testId, mappedTestId);
} else {
mappedTestId = TEST_IDS.get(testId);
}

if (mappedTestId != 0 && view.getId() != mappedTestId) {
ORIGINAL_REACT_TAGS.put(System.identityHashCode(view), view.getId());
hasTagMappings = true;
view.setId(mappedTestId);
}
}

/**
* Returns the tag originally generated by the JS when the view was created prior to
* it being potentially overridden by {@link #setTestId(View, String)}. If no view has had it's Id
* overridden by {@link #setTestId(View, String)} then this method simply returns view.getId().
* In non __DEV__ environments this should be as performant as calling view.getId().
*
* @param view
* @param <T>
* @return
*/
public static <T extends View> int getOriginalReactTag(T view) {
if (!hasTagMappings) {
return view.getId();
}
Integer tag = ORIGINAL_REACT_TAGS.get(System.identityHashCode(view));
return tag != null ? tag.intValue() : view.getId();
}

/**
* Removes the internal mapping of this view's identity to it's original react tag set from JS
* and restores it's Id prior to it having been overridden by setTestId.
*
* @param view
* @param <T>
*/
public static <T extends View> void removeMapping(T view) {
if (!hasTagMappings) {
return;
}
int identityHashCode = System.identityHashCode(view);
Integer originalReactTag = ORIGINAL_REACT_TAGS.get(identityHashCode);
if (originalReactTag != null) {
view.setId(originalReactTag);
ORIGINAL_REACT_TAGS.remove(identityHashCode);
}
return;
}

/**
* Used by tests to clear the static member Maps.
*/
@VisibleForTesting
public static void resetTestState() {
ORIGINAL_REACT_TAGS.clear();
TEST_IDS.clear();
}
}
1 change: 1 addition & 0 deletions ReactAndroid/src/main/java/com/facebook/react/touch/BUCK
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ android_library(
deps = [
react_native_dep('third-party/java/infer-annotations:infer-annotations'),
react_native_dep('third-party/java/jsr-305:jsr-305'),
react_native_target('java/com/facebook/react/common:common'),
],
visibility = [
'PUBLIC'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
import android.view.ViewGroup;
import android.view.ViewParent;

import static com.facebook.react.common.TestIdUtil.getOriginalReactTag;

/**
* This class coordinates JSResponder commands for {@link UIManagerModule}. It should be set as
* OnInterceptTouchEventListener for all newly created native views that implements
Expand Down Expand Up @@ -70,7 +72,7 @@ public boolean onInterceptTouchEvent(ViewGroup v, MotionEvent event) {
// Therefore since "UP" event is the last event in a gesture, we should just let it reach the
// original target that is a child view of {@param v}.
// http://developer.android.com/reference/android/view/ViewGroup.html#onInterceptTouchEvent(android.view.MotionEvent)
return v.getId() == currentJSResponder;
return getOriginalReactTag(v) == currentJSResponder;
}
return false;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
import android.graphics.Color;
import android.os.Build;
import android.view.View;
import android.view.ViewGroup;

import com.facebook.react.bridge.ReadableArray;
import com.facebook.react.common.TestIdUtil;
import com.facebook.react.uimanager.annotations.ReactProp;

/**
Expand Down Expand Up @@ -84,9 +84,16 @@ public void setRenderToHardwareTexture(T view, boolean useHWTexture) {

@ReactProp(name = PROP_TEST_ID)
public void setTestId(T view, String testId) {
TestIdUtil.setTestId(view, testId);
view.setTag(testId);
}

@Override
public void onDropViewInstance(T view) {
super.onDropViewInstance(view);
TestIdUtil.removeMapping(view);
}

@ReactProp(name = PROP_ACCESSIBILITY_LABEL)
public void setAccessibilityLabel(T view, String accessibilityLabel) {
view.setContentDescription(accessibilityLabel);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.bridge.SoftAssertions;
import com.facebook.react.bridge.UiThreadUtil;
import com.facebook.react.common.TestIdUtil;
import com.facebook.react.touch.JSResponderHandler;
import com.facebook.react.uimanager.layoutanimation.LayoutAnimationController;
import com.facebook.react.uimanager.layoutanimation.LayoutAnimationListener;
Expand All @@ -40,6 +41,8 @@
import javax.annotation.Nullable;
import javax.annotation.concurrent.NotThreadSafe;

import static com.facebook.react.common.TestIdUtil.getOriginalReactTag;

/**
* Delegate of {@link UIManagerModule} that owns the native view hierarchy and mapping between
* native view names used in JS and corresponding instances of {@link ViewManager}. The
Expand Down Expand Up @@ -200,6 +203,19 @@ private void updateLayout(View viewToUpdate, int x, int y, int width, int height
}
}

/**
* Creates a {@link View} and adds it to a corresponding {@link ViewManager}.
*
* The tag (a.k.a. React Tag) is saved with {@link View#setId(int)}. Because testID can override
* this value (see {@link BaseViewManager#setTestId(View, String)}), it is necessary to use
* {@link TestIdUtil#getOriginalReactTag(View)} wherever the original tag is needed
* e.g. when communicating with the Shadow DOM or with JS about a particular react tag.
*
* @param themedContext
* @param tag
* @param className
* @param initialProps
*/
public void createView(
ThemedReactContext themedContext,
int tag,
Expand All @@ -218,10 +234,6 @@ public void createView(
View view = viewManager.createView(themedContext, mJSResponderHandler);
mTagsToViews.put(tag, view);
mTagsToViewManagers.put(tag, viewManager);

// Use android View id field to store React tag. This is possible since we don't inflate
// React views from layout xmls. Thus it is easier to just reuse that field instead of
// creating another (potentially much more expensive) mapping from view to React tag
view.setId(tag);
if (initialProps != null) {
viewManager.updateProperties(view, initialProps);
Expand Down Expand Up @@ -365,7 +377,7 @@ public void manageChildren(

if (mLayoutAnimationEnabled &&
mLayoutAnimator.shouldAnimateLayout(viewToRemove) &&
arrayContains(tagsToDelete, viewToRemove.getId())) {
arrayContains(tagsToDelete, getOriginalReactTag(viewToRemove))) {
// The view will be removed and dropped by the 'delete' layout animation
// instead, so do nothing
} else {
Expand Down Expand Up @@ -519,24 +531,25 @@ protected final void addRootViewGroup(
*/
protected void dropView(View view) {
UiThreadUtil.assertOnUiThread();
if (!mRootTags.get(view.getId())) {
int originalReactTag = getOriginalReactTag(view);
if (!mRootTags.get(originalReactTag)) {
// For non-root views we notify viewmanager with {@link ViewManager#onDropInstance}
resolveViewManager(view.getId()).onDropViewInstance(view);
resolveViewManager(originalReactTag).onDropViewInstance(view);
}
ViewManager viewManager = mTagsToViewManagers.get(view.getId());
ViewManager viewManager = mTagsToViewManagers.get(originalReactTag);
if (view instanceof ViewGroup && viewManager instanceof ViewGroupManager) {
ViewGroup viewGroup = (ViewGroup) view;
ViewGroupManager viewGroupManager = (ViewGroupManager) viewManager;
for (int i = viewGroupManager.getChildCount(viewGroup) - 1; i >= 0; i--) {
View child = viewGroupManager.getChildAt(viewGroup, i);
if (mTagsToViews.get(child.getId()) != null) {
if (mTagsToViews.get(getOriginalReactTag(child)) != null) {
dropView(child);
}
}
viewGroupManager.removeAllViews(viewGroup);
}
mTagsToViews.remove(view.getId());
mTagsToViewManagers.remove(view.getId());
mTagsToViews.remove(originalReactTag);
mTagsToViewManagers.remove(originalReactTag);
}

public void removeRootView(int rootViewTag) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
import com.facebook.react.bridge.UiThreadUtil;
import com.facebook.react.touch.ReactHitSlopView;

import static com.facebook.react.common.TestIdUtil.getOriginalReactTag;

/**
* Class responsible for identifying which react view should handle a given {@link MotionEvent}.
* It uses the event coordinates to traverse the view hierarchy and return a suitable view.
Expand Down Expand Up @@ -87,7 +89,7 @@ public static int findTargetTagAndCoordinatesForTouch(
float[] viewCoords,
@Nullable int[] nativeViewTag) {
UiThreadUtil.assertOnUiThread();
int targetTag = viewGroup.getId();
int targetTag = getOriginalReactTag(viewGroup);
// Store eventCoords in array so that they are modified to be relative to the targetView found.
viewCoords[0] = eventX;
viewCoords[1] = eventY;
Expand All @@ -96,7 +98,7 @@ public static int findTargetTagAndCoordinatesForTouch(
View reactTargetView = findClosestReactAncestor(nativeTargetView);
if (reactTargetView != null) {
if (nativeViewTag != null) {
nativeViewTag[0] = reactTargetView.getId();
nativeViewTag[0] = getOriginalReactTag(reactTargetView);
}
targetTag = getTouchTargetForView(reactTargetView, viewCoords[0], viewCoords[1]);
}
Expand Down Expand Up @@ -224,7 +226,7 @@ private static boolean isTransformedTouchPointInView(
// ViewGroup).
if (view instanceof ReactCompoundView) {
int reactTag = ((ReactCompoundView)view).reactTagForTouch(eventCoords[0], eventCoords[1]);
if (reactTag != view.getId()) {
if (reactTag != getOriginalReactTag(view)) {
// make sure we exclude the View itself because of the PointerEvents.BOX_NONE
return view;
}
Expand Down Expand Up @@ -256,7 +258,7 @@ private static int getTouchTargetForView(View targetView, float eventX, float ev
// {@link #findTouchTargetView()}.
return ((ReactCompoundView) targetView).reactTagForTouch(eventX, eventY);
}
return targetView.getId();
return getOriginalReactTag(targetView);
}

}
Loading

0 comments on commit 1a69e13

Please sign in to comment.