diff --git a/ReactAndroid/build.gradle b/ReactAndroid/build.gradle index 850d2f41f..ae79f43cb 100644 --- a/ReactAndroid/build.gradle +++ b/ReactAndroid/build.gradle @@ -217,6 +217,8 @@ android { } buildConfigField 'boolean', 'IS_INTERNAL_BUILD', 'false' + testApplicationId "com.facebook.react.tests" + testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" } sourceSets.main { @@ -258,6 +260,8 @@ dependencies { testCompile "org.mockito:mockito-core:${MOCKITO_CORE_VERSION}" testCompile "org.easytesting:fest-assert-core:${FEST_ASSERT_CORE_VERSION}" testCompile "org.robolectric:robolectric:${ROBOLECTRIC_VERSION}" + + androidTestCompile "com.android.support.test:testing-support-lib:0.1" } apply from: 'release.gradle' diff --git a/ReactAndroid/src/androidTest/AndroidManifest.xml b/ReactAndroid/src/androidTest/AndroidManifest.xml new file mode 100644 index 000000000..2363aff11 --- /dev/null +++ b/ReactAndroid/src/androidTest/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + diff --git a/ReactAndroid/src/androidTest/assets/ScrollViewTestModule.js b/ReactAndroid/src/androidTest/assets/ScrollViewTestModule.js new file mode 100644 index 000000000..0a2f5143a --- /dev/null +++ b/ReactAndroid/src/androidTest/assets/ScrollViewTestModule.js @@ -0,0 +1,136 @@ +/** + * @providesModule ScrollViewTestModule + * @jsx React.DOM + */ + +'use strict'; + +var BatchedBridge = require('BatchedBridge'); +var React = require('React'); +var View = require('View'); +var ScrollView = require('ScrollView'); +var Text = require('Text'); +var StyleSheet = require('StyleSheet'); +var TouchableWithoutFeedback = require('TouchableWithoutFeedback'); +var ScrollListener = require('NativeModules').ScrollListener; + +var NUM_ITEMS = 100; + +// Shared by integration tests for ScrollView and HorizontalScrollView + +var scrollViewApp; + +var Item = React.createClass({ + render: function() { + return ( + + + {this.props.text} + + + ); + }, +}); + +var getInitialState = function() { + var data = []; + for (var i = 0; i < NUM_ITEMS; i++) { + data[i] = {text: 'Item ' + i + '!'}; + } + return { + data: data, + }; +}; + +var onScroll = function(e) { + ScrollListener.onScroll(e.nativeEvent.contentOffset.x, e.nativeEvent.contentOffset.y); +}; + +var onScrollBeginDrag = function(e) { + ScrollListener.onScrollBeginDrag(e.nativeEvent.contentOffset.x, e.nativeEvent.contentOffset.y); +}; + +var onScrollEndDrag = function(e) { + ScrollListener.onScrollEndDrag(e.nativeEvent.contentOffset.x, e.nativeEvent.contentOffset.y); +}; + +var onItemPress = function(itemNumber) { + ScrollListener.onItemPress(itemNumber); +}; + +var ScrollViewTestApp = React.createClass({ + getInitialState: getInitialState, + onScroll: onScroll, + onItemPress: onItemPress, + onScrollBeginDrag: onScrollBeginDrag, + onScrollEndDrag: onScrollEndDrag, + + scrollTo: function(destX, destY) { + this.refs.scrollView.scrollTo(destY, destX); + }, + + render: function() { + scrollViewApp = this; + var children = this.state.data.map((item, index) => ( + + )); + return ( + + {children} + + ); + }, +}); + +var HorizontalScrollViewTestApp = React.createClass({ + getInitialState: getInitialState, + onScroll: onScroll, + onItemPress: onItemPress, + + scrollTo: function(destX, destY) { + this.refs.scrollView.scrollTo(destY, destX); + }, + + render: function() { + scrollViewApp = this; + var children = this.state.data.map((item, index) => ( + + )); + return ( + + {children} + + ); + }, +}); + +var styles = StyleSheet.create({ + item_container: { + padding: 30, + backgroundColor: '#ffffff', + }, + item_text: { + flex: 1, + fontSize: 18, + alignSelf: 'center', + }, +}); + +var ScrollViewTestModule = { + ScrollViewTestApp: ScrollViewTestApp, + HorizontalScrollViewTestApp: HorizontalScrollViewTestApp, + scrollTo: function(destX, destY) { + scrollViewApp.scrollTo(destX, destY); + }, +}; + +BatchedBridge.registerCallableModule( + 'ScrollViewTestModule', + ScrollViewTestModule +); + +module.exports = ScrollViewTestModule; diff --git a/ReactAndroid/src/androidTest/assets/TestBundle.js b/ReactAndroid/src/androidTest/assets/TestBundle.js new file mode 100644 index 000000000..1bbdad734 --- /dev/null +++ b/ReactAndroid/src/androidTest/assets/TestBundle.js @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. + * All rights reserved. + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ +'use strict'; + +// Disable YellowBox so we do not have to mock its dependencies +console.disableYellowBox = true; + +// Include modules used by integration tests +require('ScrollViewTestModule'); + +// Define catalyst test apps used in integration tests +var AppRegistry = require('AppRegistry'); + +var apps = [ +{ + appKey: 'ScrollViewTestApp', + component: () => require('ScrollViewTestModule').ScrollViewTestApp +}, +{ + appKey: 'HorizontalScrollViewTestApp', + component: () => require('ScrollViewTestModule').HorizontalScrollViewTestApp +}, +]; + +AppRegistry.registerConfig(apps); diff --git a/ReactAndroid/src/androidTest/java/com/facebook/react/testing/AbstractScrollViewTestCase.java b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/AbstractScrollViewTestCase.java new file mode 100644 index 000000000..afdf4bc90 --- /dev/null +++ b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/AbstractScrollViewTestCase.java @@ -0,0 +1,114 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. + * All rights reserved. + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.testing; + +import java.util.ArrayList; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; + +import com.facebook.react.bridge.BaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.bridge.JavaScriptModule; +import com.facebook.react.testing.ReactInstanceSpecForTest; +import com.facebook.react.testing.ReactAppInstrumentationTestCase; + +/** + * Shared by {@link ReactScrollViewTestCase} and {@link ReactHorizontalScrollViewTestCase}. + * See also ScrollViewTestModule.js + */ +public abstract class AbstractScrollViewTestCase extends ReactAppInstrumentationTestCase { + + protected ScrollListenerModule mScrollListenerModule; + + protected static interface ScrollViewTestModule extends JavaScriptModule { + public void scrollTo(float destX, float destY); + } + + @Override + protected void tearDown() throws Exception { + waitForBridgeAndUIIdle(60000); + super.tearDown(); + } + + @Override + protected ReactInstanceSpecForTest createReactInstanceSpecForTest() { + mScrollListenerModule = new ScrollListenerModule(); + return super.createReactInstanceSpecForTest() + .addNativeModule(mScrollListenerModule) + .addJSModule(ScrollViewTestModule.class); + } + + // See ScrollViewListenerModule.js + protected static class ScrollListenerModule extends BaseJavaModule { + + private final ArrayList mXOffsets = new ArrayList(); + private final ArrayList mYOffsets = new ArrayList(); + private final ArrayList mItemsPressed = new ArrayList(); + private final Semaphore mScrollSignaler = new Semaphore(0); + private boolean mScrollBeginDragCalled; + private boolean mScrollEndDragCalled; + + // Matches ScrollViewListenerModule.js + @Override + public String getName() { + return "ScrollListener"; + } + + @ReactMethod + public void onScroll(double x, double y) { + mXOffsets.add(x); + mYOffsets.add(y); + mScrollSignaler.release(); + } + + @ReactMethod + public void onItemPress(int itemNumber) { + mItemsPressed.add(itemNumber); + } + + @ReactMethod + public void onScrollBeginDrag(double x, double y) { + mScrollBeginDragCalled = true; + } + + @ReactMethod + public void onScrollEndDrag(double x, double y) { + mScrollEndDragCalled = true; + } + + public void waitForScrollIdle() { + while (true) { + try { + boolean gotScrollSignal = mScrollSignaler.tryAcquire(1000, TimeUnit.MILLISECONDS); + if (!gotScrollSignal) { + return; + } + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + } + + public ArrayList getXOffsets() { + return mXOffsets; + } + + public ArrayList getYOffsets() { + return mYOffsets; + } + + public ArrayList getItemsPressed() { + return mItemsPressed; + } + + public boolean dragEventsMatch() { + return mScrollBeginDragCalled && mScrollEndDragCalled; + } + } +} diff --git a/ReactAndroid/src/androidTest/java/com/facebook/react/testing/AssertModule.java b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/AssertModule.java new file mode 100644 index 000000000..437947016 --- /dev/null +++ b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/AssertModule.java @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. + * All rights reserved. + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.testing; + +import javax.annotation.Nullable; + +import com.facebook.react.bridge.BaseJavaModule; +import com.facebook.react.bridge.ReactMethod; + +import static junit.framework.Assert.assertFalse; +import static junit.framework.Assert.assertTrue; + +/** + * NativeModule for tests that allows assertions from JS to propagate to Java. + */ +public class AssertModule extends BaseJavaModule { + + private boolean mGotSuccess; + private boolean mGotFailure; + private @Nullable String mFirstFailureStackTrace; + + @Override + public String getName() { + return "Assert"; + } + + @ReactMethod + public void fail(String stackTrace) { + if (!mGotFailure) { + mGotFailure = true; + mFirstFailureStackTrace = stackTrace; + } + } + + @ReactMethod + public void success() { + mGotSuccess = true; + } + + /** + * Allows the user of this module to verify that asserts are actually being called from JS and + * that none of them failed. + */ + public void verifyAssertsAndReset() { + assertFalse("First failure: " + mFirstFailureStackTrace, mGotFailure); + assertTrue("Received no assertions during the test!", mGotSuccess); + mGotFailure = false; + mGotSuccess = false; + mFirstFailureStackTrace = null; + } +} diff --git a/ReactAndroid/src/androidTest/java/com/facebook/react/testing/FakeAsyncLocalStorage.java b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/FakeAsyncLocalStorage.java new file mode 100644 index 000000000..e62d09ecb --- /dev/null +++ b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/FakeAsyncLocalStorage.java @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. + * All rights reserved. + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.testing; + +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.BaseJavaModule; +import com.facebook.react.bridge.Callback; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.bridge.ReadableArray; +import com.facebook.react.bridge.WritableMap; + +/** + * Dummy implementation of storage module, used for testing + */ +public final class FakeAsyncLocalStorage extends BaseJavaModule { + + private static WritableMap errorMessage; + static { + errorMessage = Arguments.createMap(); + errorMessage.putString("message", "Fake Async Local Storage"); + } + + @Override + public String getName() { + return "AsyncSQLiteDBStorage"; + } + + @ReactMethod + public void multiGet(final ReadableArray keys, final Callback callback) { + callback.invoke(errorMessage, null); + } + + @ReactMethod + public void multiSet(final ReadableArray keyValueArray, final Callback callback) { + callback.invoke(errorMessage); + } + + @ReactMethod + public void multiRemove(final ReadableArray keys, final Callback callback) { + callback.invoke(errorMessage); + } + + @ReactMethod + public void clear(Callback callback) { + callback.invoke(errorMessage); + } + + @ReactMethod + public void getAllKeys(final Callback callback) { + callback.invoke(errorMessage, null); + } +} diff --git a/ReactAndroid/src/androidTest/java/com/facebook/react/testing/IdleWaiter.java b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/IdleWaiter.java new file mode 100644 index 000000000..98884c103 --- /dev/null +++ b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/IdleWaiter.java @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. + * All rights reserved. + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.testing; + +/** + * Interface for something that knows how to wait for bridge and UI idle. + */ +public interface IdleWaiter { + + void waitForBridgeAndUIIdle(); +} diff --git a/ReactAndroid/src/androidTest/java/com/facebook/react/testing/InstanceSpecForTestPackage.java b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/InstanceSpecForTestPackage.java new file mode 100644 index 000000000..cb7bbf315 --- /dev/null +++ b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/InstanceSpecForTestPackage.java @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. + * All rights reserved. + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.testing; + +import java.util.List; + +import android.view.View; + +import com.facebook.react.ReactPackage; +import com.facebook.react.bridge.NativeModule; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.JavaScriptModule; +import com.facebook.react.uimanager.ViewManager; +import com.facebook.react.ReactPackage; + +/** + * This class wraps {@class ReactInstanceSpecForTest} in {@class ReactPackage} interface. + * TODO(6788898): Refactor test code to use ReactPackage instead of SpecForTest + */ +public class InstanceSpecForTestPackage implements ReactPackage { + + private final ReactInstanceSpecForTest mSpecForTest; + + public InstanceSpecForTestPackage(ReactInstanceSpecForTest specForTest) { + mSpecForTest = specForTest; + } + + @Override + public List createNativeModules( + ReactApplicationContext catalystApplicationContext) { + return mSpecForTest.getExtraNativeModulesForTest(); + } + + @Override + public List> createJSModules() { + return mSpecForTest.getExtraJSModulesForTest(); + } + + @Override + public List createViewManagers(ReactApplicationContext reactContext) { + return mSpecForTest.getExtraViewManagers(); + } +} diff --git a/ReactAndroid/src/androidTest/java/com/facebook/react/testing/JSIntegrationTestChecker.java b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/JSIntegrationTestChecker.java new file mode 100644 index 000000000..1ddf186c9 --- /dev/null +++ b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/JSIntegrationTestChecker.java @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. + * All rights reserved. + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.testing; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import com.facebook.react.bridge.BaseJavaModule; +import com.facebook.react.bridge.Callback; +import com.facebook.react.bridge.ReactMethod; + +/** + * This class is used to verify that some JS integration tests have completed successfully. + * The JS integration tests can be started from a ReactIntegrationTestCase and upon + * finishing successfully the {@link JSIntegrationTestChecker#testDone()} method will be called. + * To verify if the test has completed successfully, call {#link JSIntegrationTestChecker#await()} + * to wait for the test to run, and {#link JSIntegrationTestChecker#isTestDone()} to check if it + * completed successfully. + */ +public class JSIntegrationTestChecker extends BaseJavaModule { + + private final CountDownLatch mLatch; + + public JSIntegrationTestChecker() { + mLatch = new CountDownLatch(1); + } + + @Override + public String getName() { + return "TestModule"; + } + + @ReactMethod + public void markTestCompleted() { + mLatch.countDown(); + } + + @ReactMethod + public void verifySnapshot(Callback callback) { + } + + public boolean await(long ms) { + try { + return mLatch.await(ms, TimeUnit.MILLISECONDS); + } catch (InterruptedException ignore) { + return false; + } + } + + public boolean isTestDone() { + return mLatch.getCount() == 0; + } +} diff --git a/ReactAndroid/src/androidTest/java/com/facebook/react/testing/MultipleFailureException.java b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/MultipleFailureException.java new file mode 100644 index 000000000..a996403d4 --- /dev/null +++ b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/MultipleFailureException.java @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. + * All rights reserved. + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.testing; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.List; + +/** + * Custom implementation of {@link org.junit.runners.model.MultipleFailureException} that includes + * stack information of collected exception as a part of the message. + */ +public class MultipleFailureException extends org.junit.runners.model.MultipleFailureException { + + public MultipleFailureException(List errors) { + super(errors); + } + + @Override + public String getMessage() { + StringBuilder sb = new StringBuilder(); + List errors = getFailures(); + + sb.append(String.format("There were %d errors:", errors.size())); + + int i = 0; + for (Throwable e : errors) { + sb.append(String.format("%n---- Error #%d", i)); + sb.append("\n" + getStackTraceAsString(e)); + i++; + } + sb.append("\n"); + return sb.toString(); + } + + private static String getStackTraceAsString(Throwable throwable) { + StringWriter stringWriter = new StringWriter(); + throwable.printStackTrace(new PrintWriter(stringWriter)); + return stringWriter.toString(); + } +} diff --git a/ReactAndroid/src/androidTest/java/com/facebook/react/testing/NetworkRecordingModuleMock.java b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/NetworkRecordingModuleMock.java new file mode 100644 index 000000000..b23ba1e73 --- /dev/null +++ b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/NetworkRecordingModuleMock.java @@ -0,0 +1,138 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. + * All rights reserved. + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.testing; + +import javax.annotation.Nullable; + +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.Callback; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.bridge.ReadableArray; +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.WritableArray; +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.modules.core.DeviceEventManagerModule; + +/** + * Mock Networking module that records last request received by {@link #sendRequest} method and + * returns reponse code and body that should be set with {@link #setResponse} + */ +public class NetworkRecordingModuleMock extends ReactContextBaseJavaModule { + + public int mRequestCount = 0; + public @Nullable String mRequestMethod; + public @Nullable String mRequestURL; + public @Nullable ReadableArray mRequestHeaders; + public @Nullable ReadableMap mRequestData; + public int mLastRequestId; + public boolean mAbortedRequest; + + private final boolean mCompleteRequest; + + public NetworkRecordingModuleMock(ReactApplicationContext reactContext) { + this(reactContext, true); + } + + public NetworkRecordingModuleMock(ReactApplicationContext reactContext, boolean completeRequest) { + super(reactContext); + mCompleteRequest = completeRequest; + } + + public static interface RequestListener { + public void onRequest(String method, String url, ReadableArray header, ReadableMap data); + } + + private int mResponseCode; + private @Nullable String mResponseBody; + private @Nullable RequestListener mRequestListener; + + public void setResponse(int code, String body) { + mResponseCode = code; + mResponseBody = body; + } + + public void setRequestListener(RequestListener requestListener) { + mRequestListener = requestListener; + } + + @Override + public final String getName() { + return "RCTNetworking"; + } + + private void fireReactCallback( + Callback callback, + int status, + @Nullable String headers, + @Nullable String body) { + callback.invoke(status, headers, body); + } + + @ReactMethod + public final void sendRequest( + String method, + String url, + int requestId, + ReadableArray headers, + ReadableMap data, + boolean incrementalUpdates) { + mLastRequestId = requestId; + mRequestCount++; + mRequestMethod = method; + mRequestURL = url; + mRequestHeaders = headers; + mRequestData = data; + if (mRequestListener != null) { + mRequestListener.onRequest(method, url, headers, data); + } + if (mCompleteRequest) { + onResponseReceived(requestId, mResponseCode, null); + onDataReceived(requestId, mResponseBody); + onRequestComplete(requestId, null); + } + } + + @ReactMethod + public void abortRequest(int requestId) { + mLastRequestId = requestId; + mAbortedRequest = true; + } + + private void onDataReceived(int requestId, String data) { + WritableArray args = Arguments.createArray(); + args.pushInt(requestId); + args.pushString(data); + + getEventEmitter().emit("didReceiveNetworkData", args); + } + + private void onRequestComplete(int requestId, @Nullable String error) { + WritableArray args = Arguments.createArray(); + args.pushInt(requestId); + args.pushString(error); + + getEventEmitter().emit("didCompleteNetworkResponse", args); + } + + private void onResponseReceived(int requestId, int code, WritableMap headers) { + WritableArray args = Arguments.createArray(); + args.pushInt(requestId); + args.pushInt(code); + args.pushMap(headers); + + getEventEmitter().emit("didReceiveNetworkResponse", args); + } + + private DeviceEventManagerModule.RCTDeviceEventEmitter getEventEmitter() { + return getReactApplicationContext() + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class); + } +} diff --git a/ReactAndroid/src/androidTest/java/com/facebook/react/testing/ReactAppInstrumentationTestCase.java b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/ReactAppInstrumentationTestCase.java new file mode 100644 index 000000000..aaba651de --- /dev/null +++ b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/ReactAppInstrumentationTestCase.java @@ -0,0 +1,166 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. + * All rights reserved. + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.testing; + +import javax.annotation.Nullable; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import android.graphics.Bitmap; +import android.test.ActivityInstrumentationTestCase2; +import android.view.View; +import android.view.ViewGroup; + +import com.facebook.infer.annotation.Assertions; +import com.facebook.react.bridge.ReactContext; + +/** + * Base class for instrumentation tests that runs React based react application in UI mode + */ +public abstract class ReactAppInstrumentationTestCase extends + ActivityInstrumentationTestCase2 implements IdleWaiter { + + public ReactAppInstrumentationTestCase() { + super(ReactAppTestActivity.class); + } + + @Override + protected void setUp() throws Exception { + super.setUp(); + + final ReactAppTestActivity activity = getActivity(); + try { + runTestOnUiThread(new Runnable() { + @Override + public void run() { + activity.loadApp( + getReactApplicationKeyUnderTest(), + createReactInstanceSpecForTest(), + getEnableDevSupport()); + } + }); + } catch (Throwable t) { + throw new Exception("Unable to load react app", t); + } + waitForBridgeAndUIIdle(); + assertTrue("Layout never occurred!", activity.waitForLayout(5000)); + waitForBridgeAndUIIdle(); + } + + @Override + protected void tearDown() throws Exception { + ReactAppTestActivity activity = getActivity(); + super.tearDown(); + activity.waitForDestroy(5000); + } + + public ViewGroup getRootView() { + return (ViewGroup) getActivity().getRootView(); + } + + /** + * This method isn't safe since it doesn't factor in layout-only view removal. Use + * {@link #getViewByTestId(String)} instead. + */ + @Deprecated + public T getViewAtPath(int... path) { + return ReactTestHelper.getViewAtPath((ViewGroup) getRootView().getParent(), path); + } + + public T getViewByTestId(String testID) { + return (T) ReactTestHelper + .getViewWithReactTestId((ViewGroup) getRootView().getParent(), testID); + } + + public SingleTouchGestureGenerator createGestureGenerator() { + return new SingleTouchGestureGenerator(getRootView(), this); + } + + public void waitForBridgeAndUIIdle() { + getActivity().waitForBridgeAndUIIdle(); + } + + public void waitForBridgeAndUIIdle(long timeoutMs) { + getActivity().waitForBridgeAndUIIdle(timeoutMs); + } + + protected Bitmap getScreenshot() { + // Wait for the UI to settle. If the UI is doing animations, this may be unsafe! + getInstrumentation().waitForIdleSync(); + + final CountDownLatch latch = new CountDownLatch(1); + final BitmapHolder bitmapHolder = new BitmapHolder(); + final Runnable getScreenshotRunnable = new Runnable() { + + private static final int MAX_TRIES = 1000; + // This is the constant used in the support library for APIs that don't have Choreographer + private static final int FRAME_DELAY_MS = 10; + + private int mNumRuns = 0; + + @Override + public void run() { + mNumRuns++; + ReactAppTestActivity activity = getActivity(); + if (!activity.isScreenshotReady()) { + if (mNumRuns > MAX_TRIES) { + throw new RuntimeException( + "Waited " + MAX_TRIES + " frames to get screenshot but it's still not ready!"); + } + activity.postDelayed(this, FRAME_DELAY_MS); + return; + } + + bitmapHolder.bitmap = getActivity().getCurrentScreenshot(); + latch.countDown(); + } + }; + + + getActivity().runOnUiThread(getScreenshotRunnable); + try { + if (!latch.await(5000, TimeUnit.MILLISECONDS)) { + throw new RuntimeException("Timed out waiting for screenshot runnable to run!"); + } + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + return Assertions.assertNotNull(bitmapHolder.bitmap); + } + + /** + * Implement this method to provide application key to be launched. List of available + * application is located in TestBundle.js file + */ + protected abstract String getReactApplicationKeyUnderTest(); + + protected boolean getEnableDevSupport() { + return false; + } + + /** + * Override this method to provide extra native modules to be loaded before the app starts + */ + protected ReactInstanceSpecForTest createReactInstanceSpecForTest() { + return new ReactInstanceSpecForTest(); + } + + protected ReactContext getReactContext() { + return getActivity().getReactContext(); + } + + /** + * Helper class to pass the bitmap between execution scopes in {@link #getScreenshot()}. + */ + private static class BitmapHolder { + + public @Nullable volatile Bitmap bitmap; + } +} diff --git a/ReactAndroid/src/androidTest/java/com/facebook/react/testing/ReactAppTestActivity.java b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/ReactAppTestActivity.java new file mode 100644 index 000000000..734cdfd2c --- /dev/null +++ b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/ReactAppTestActivity.java @@ -0,0 +1,236 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. + * All rights reserved. + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.testing; + +import javax.annotation.Nullable; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import android.graphics.Bitmap; +import android.os.Bundle; +import android.support.v4.app.FragmentActivity; +import android.view.View; +import android.view.ViewTreeObserver; +import android.widget.FrameLayout; + +import com.facebook.infer.annotation.Assertions; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.LifecycleState; +import com.facebook.react.modules.core.DefaultHardwareBackBtnHandler; +import com.facebook.react.ReactInstanceManager; +import com.facebook.react.ReactPackage; +import com.facebook.react.ReactRootView; +import com.facebook.react.shell.MainReactPackage; + + +public class ReactAppTestActivity extends FragmentActivity implements + DefaultHardwareBackBtnHandler +{ + + private static final String DEFAULT_BUNDLE_NAME = "AndroidTestBundle.js"; + private static final int ROOT_VIEW_ID = 8675309; + private static final long IDLE_TIMEOUT_MS = 15000; + + private CountDownLatch mLayoutEvent = new CountDownLatch(1); + private @Nullable ReactBridgeIdleSignaler mBridgeIdleSignaler; + private ScreenshotingFrameLayout mScreenshotingFrameLayout; + private final CountDownLatch mDestroyCountDownLatch = new CountDownLatch(1); + private @Nullable ReactInstanceManager mReactInstanceManager; + private @Nullable ReactRootView mReactRootView; + private LifecycleState mLifecycleState = LifecycleState.BEFORE_RESUME; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + overridePendingTransition(0, 0); + + // We wrap screenshot layout in another FrameLayout in order to handle custom dimensions of the + // screenshot view set through {@link #setScreenshotDimensions} + FrameLayout rootView = new FrameLayout(this); + setContentView(rootView); + + mScreenshotingFrameLayout = new ScreenshotingFrameLayout(this); + mScreenshotingFrameLayout.setId(ROOT_VIEW_ID); + rootView.addView(mScreenshotingFrameLayout); + + mReactRootView = new ReactRootView(this); + mScreenshotingFrameLayout.addView(mReactRootView); + } + + @Override + protected void onPause() { + super.onPause(); + + mLifecycleState = LifecycleState.BEFORE_RESUME; + + overridePendingTransition(0, 0); + + if (mReactInstanceManager != null) { + mReactInstanceManager.onPause(); + } + } + + @Override + protected void onResume() { + super.onResume(); + + mLifecycleState = LifecycleState.RESUMED; + + if (mReactInstanceManager != null) { + mReactInstanceManager.onResume(this, this); + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + mDestroyCountDownLatch.countDown(); + + if (mReactInstanceManager != null) { + mReactInstanceManager.onDestroy(); + } + } + + public void waitForDestroy(long timeoutMs) throws InterruptedException { + mDestroyCountDownLatch.await(timeoutMs, TimeUnit.MILLISECONDS); + } + + public void loadApp(String appKey, ReactInstanceSpecForTest spec, boolean enableDevSupport) { + loadApp(appKey, spec, null, DEFAULT_BUNDLE_NAME, enableDevSupport); + } + + public void loadApp(String appKey, ReactInstanceSpecForTest spec, String bundleName) { + loadApp(appKey, spec, null, bundleName, false /* = useDevSupport */); + } + + public void resetRootViewForScreenshotTests() { + if (mReactInstanceManager != null) { + mReactInstanceManager.onDestroy(); + mReactInstanceManager = null; + } + mReactRootView = new ReactRootView(this); + mScreenshotingFrameLayout.removeAllViews(); + mScreenshotingFrameLayout.addView(mReactRootView); + } + + public void loadApp( + String appKey, + ReactInstanceSpecForTest spec, + @Nullable Bundle initialProps, + String bundleName, + boolean useDevSupport) { + + final CountDownLatch currentLayoutEvent = mLayoutEvent = new CountDownLatch(1); + mBridgeIdleSignaler = new ReactBridgeIdleSignaler(); + + ReactInstanceManager.Builder builder = ReactInstanceManager.builder() + .setApplication(getApplication()) + .setBundleAssetName(bundleName) + // By not setting a JS module name, we force the bundle to be always loaded from + // assets, not the devserver, even if dev mode is enabled (such as when testing redboxes). + // This makes sense because we never run the devserver in tests. + //.setJSMainModuleName() + .addPackage(spec.getAlternativeReactPackageForTest() != null ? + spec.getAlternativeReactPackageForTest() : new MainReactPackage()) + .addPackage(new InstanceSpecForTestPackage(spec)) + .setUseDeveloperSupport(useDevSupport) + .setBridgeIdleDebugListener(mBridgeIdleSignaler) + .setInitialLifecycleState(mLifecycleState); + + mReactInstanceManager = builder.build(); + mReactInstanceManager.onResume(this, this); + + Assertions.assertNotNull(mReactRootView).getViewTreeObserver().addOnGlobalLayoutListener( + new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + currentLayoutEvent.countDown(); + } + }); + Assertions.assertNotNull(mReactRootView) + .startReactApplication(mReactInstanceManager, appKey, initialProps); + } + + public boolean waitForLayout(long millis) throws InterruptedException { + return mLayoutEvent.await(millis, TimeUnit.MILLISECONDS); + } + + public void waitForBridgeAndUIIdle() { + waitForBridgeAndUIIdle(IDLE_TIMEOUT_MS); + } + + public void waitForBridgeAndUIIdle(long timeoutMs) { + ReactIdleDetectionUtil.waitForBridgeAndUIIdle( + Assertions.assertNotNull(mBridgeIdleSignaler), + getReactContext(), + timeoutMs); + } + + public View getRootView() { + return Assertions.assertNotNull(mReactRootView); + } + + public ReactContext getReactContext() { + return waitForReactContext(); + } + + // Because react context is created asynchronously, we may have to wait until it is available. + // It's simpler than exposing synchronosition mechanism to notify listener than react context + // creation has completed. + private ReactContext waitForReactContext() { + Assertions.assertNotNull(mReactInstanceManager); + + try { + while (true) { + ReactContext reactContext = mReactInstanceManager.getCurrentReactContext(); + if (reactContext != null) { + return reactContext; + } + Thread.sleep(100); + } + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + public void postDelayed(Runnable r, int delayMS) { + getRootView().postDelayed(r, delayMS); + } + + /** + * Does not ensure that this is run on the UI thread or that the UI Looper is idle like + * {@link ReactAppInstrumentationTestCase#getScreenshot()}. You probably want to use that + * instead. + */ + public Bitmap getCurrentScreenshot() { + return mScreenshotingFrameLayout.getLastDrawnBitmap(); + } + + public boolean isScreenshotReady() { + return mScreenshotingFrameLayout.isScreenshotReady(); + } + + public void setScreenshotDimensions(int width, int height) { + mScreenshotingFrameLayout.setLayoutParams(new FrameLayout.LayoutParams(width, height)); + } + + @Override + public void invokeDefaultOnBackPressed() { + super.onBackPressed(); + } + + @Override + public void onRequestPermissionsResult( + int requestCode, + String[] permissions, + int[] grantResults) { + } +} diff --git a/ReactAndroid/src/androidTest/java/com/facebook/react/testing/ReactBridgeIdleSignaler.java b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/ReactBridgeIdleSignaler.java new file mode 100644 index 000000000..ffd941f9a --- /dev/null +++ b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/ReactBridgeIdleSignaler.java @@ -0,0 +1,62 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. + * All rights reserved. + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.testing; + +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; + +import com.facebook.react.bridge.NotThreadSafeBridgeIdleDebugListener; + +/** + * Utility class that uses {@link NotThreadSafeBridgeIdleDebugListener} interface to allow callers + * to wait for the bridge to be idle. + */ +public class ReactBridgeIdleSignaler implements NotThreadSafeBridgeIdleDebugListener { + + // Starts at 1 since bridge starts idle. The logic here is that the semaphore is only acquirable + // if the bridge is idle. + private final Semaphore mBridgeIdleSemaphore = new Semaphore(1); + + private volatile boolean mIsBridgeIdle = true; + + @Override + public void onTransitionToBridgeIdle() { + mIsBridgeIdle = true; + mBridgeIdleSemaphore.release(); + } + + @Override + public void onTransitionToBridgeBusy() { + mIsBridgeIdle = false; + try { + if (!mBridgeIdleSemaphore.tryAcquire(15000, TimeUnit.MILLISECONDS)) { + throw new RuntimeException( + "Timed out waiting to acquire the test idle listener semaphore. Deadlock?"); + } + } catch (InterruptedException e) { + throw new RuntimeException("Got interrupted", e); + } + } + + public boolean isBridgeIdle() { + return mIsBridgeIdle; + } + + public boolean waitForIdle(long millis) { + try { + if (mBridgeIdleSemaphore.tryAcquire(millis, TimeUnit.MILLISECONDS)) { + mBridgeIdleSemaphore.release(); + return true; + } + return false; + } catch (InterruptedException e) { + throw new RuntimeException("Got interrupted", e); + } + } +} diff --git a/ReactAndroid/src/androidTest/java/com/facebook/react/testing/ReactIdleDetectionUtil.java b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/ReactIdleDetectionUtil.java new file mode 100644 index 000000000..bd09d2133 --- /dev/null +++ b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/ReactIdleDetectionUtil.java @@ -0,0 +1,125 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. + * All rights reserved. + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.testing; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import android.app.Instrumentation; +import android.os.SystemClock; +import android.support.test.InstrumentationRegistry; +import android.view.Choreographer; + +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.bridge.UiThreadUtil; + +public class ReactIdleDetectionUtil { + + /** + * Waits for both the UI thread and bridge to be idle. It determines this by waiting for the + * bridge to become idle, then waiting for the UI thread to become idle, then checking if the + * bridge is idle again (if the bridge was idle before and is still idle after running the UI + * thread to idle, then there are no more events to process in either place). + *

+ * Also waits for any Choreographer callbacks to run after the initial sync since things like UI + * events are initiated from Choreographer callbacks. + */ + public static void waitForBridgeAndUIIdle( + ReactBridgeIdleSignaler idleSignaler, + final ReactContext reactContext, + long timeoutMs) { + UiThreadUtil.assertNotOnUiThread(); + + long startTime = SystemClock.elapsedRealtime(); + waitInner(idleSignaler, timeoutMs); + + long timeToWait = Math.max(1, timeoutMs - (SystemClock.elapsedRealtime() - startTime)); + waitForChoreographer(timeToWait); + waitForJSIdle(reactContext); + + timeToWait = Math.max(1, timeoutMs - (SystemClock.elapsedRealtime() - startTime)); + waitInner(idleSignaler, timeToWait); + timeToWait = Math.max(1, timeoutMs - (SystemClock.elapsedRealtime() - startTime)); + waitForChoreographer(timeToWait); + } + + private static void waitForChoreographer(long timeToWait) { + final int waitFrameCount = 2; + final CountDownLatch latch = new CountDownLatch(1); + UiThreadUtil.runOnUiThread( + new Runnable() { + @Override + public void run() { + Choreographer.getInstance().postFrameCallback( + new Choreographer.FrameCallback() { + + private int frameCount = 0; + + @Override + public void doFrame(long frameTimeNanos) { + frameCount++; + if (frameCount == waitFrameCount) { + latch.countDown(); + } else { + Choreographer.getInstance().postFrameCallback(this); + } + } + }); + } + }); + try { + if (!latch.await(timeToWait, TimeUnit.MILLISECONDS)) { + throw new RuntimeException("Timed out waiting for Choreographer"); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static void waitForJSIdle(ReactContext reactContext) { + if (!reactContext.hasActiveCatalystInstance()) { + return; + } + final CountDownLatch latch = new CountDownLatch(1); + + reactContext.runOnJSQueueThread( + new Runnable() { + @Override + public void run() { + latch.countDown(); + } + }); + + try { + if (!latch.await(5000, TimeUnit.MILLISECONDS)) { + throw new RuntimeException("Timed out waiting for JS thread"); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static void waitInner(ReactBridgeIdleSignaler idleSignaler, long timeToWait) { + // TODO gets broken in gradle, do we need it? + Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); + long startTime = SystemClock.elapsedRealtime(); + boolean bridgeWasIdle = false; + while (SystemClock.elapsedRealtime() - startTime < timeToWait) { + boolean bridgeIsIdle = idleSignaler.isBridgeIdle(); + if (bridgeIsIdle && bridgeWasIdle) { + return; + } + bridgeWasIdle = bridgeIsIdle; + long newTimeToWait = Math.max(1, timeToWait - (SystemClock.elapsedRealtime() - startTime)); + idleSignaler.waitForIdle(newTimeToWait); + instrumentation.waitForIdleSync(); + } + throw new RuntimeException("Timed out waiting for bridge and UI idle!"); + } +} diff --git a/ReactAndroid/src/androidTest/java/com/facebook/react/testing/ReactInstanceSpecForTest.java b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/ReactInstanceSpecForTest.java new file mode 100644 index 000000000..b9de7a1e7 --- /dev/null +++ b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/ReactInstanceSpecForTest.java @@ -0,0 +1,69 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. + * All rights reserved. + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.testing; + +import java.util.ArrayList; +import java.util.List; + +import android.annotation.SuppressLint; + +import com.facebook.react.bridge.NativeModule; +import com.facebook.react.bridge.JavaScriptModule; +import com.facebook.react.uimanager.ViewManager; +import com.facebook.react.ReactPackage; + +/** + * A spec that allows a test to add additional NativeModules/JS modules to the ReactInstance. This + * can also be used to stub out existing native modules by adding another module with the same name + * as a built-in module. + */ +@SuppressLint("JavatestsIncorrectFolder") +public class ReactInstanceSpecForTest { + + private final List mNativeModules = new ArrayList<>(); + private final List> mJSModuleSpecs = new ArrayList<>(); + private final List mViewManagers = new ArrayList<>(); + private ReactPackage mReactPackage = null; + + public ReactInstanceSpecForTest addNativeModule(NativeModule module) { + mNativeModules.add(module); + return this; + } + + public ReactInstanceSpecForTest addJSModule(Class jsClass) { + mJSModuleSpecs.add(jsClass); + return this; + } + + public ReactInstanceSpecForTest setPackage(ReactPackage reactPackage) { + mReactPackage = reactPackage; + return this; + } + + public ReactInstanceSpecForTest addViewManager(ViewManager viewManager) { + mViewManagers.add(viewManager); + return this; + } + + public List getExtraNativeModulesForTest() { + return mNativeModules; + } + + public List> getExtraJSModulesForTest() { + return mJSModuleSpecs; + } + + public ReactPackage getAlternativeReactPackageForTest() { + return mReactPackage; + } + + public List getExtraViewManagers() { + return mViewManagers; + } +} diff --git a/ReactAndroid/src/androidTest/java/com/facebook/react/testing/ReactIntegrationTestCase.java b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/ReactIntegrationTestCase.java new file mode 100644 index 000000000..e70a8428b --- /dev/null +++ b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/ReactIntegrationTestCase.java @@ -0,0 +1,212 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. + * All rights reserved. + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.testing; + +import javax.annotation.Nullable; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; + +import android.support.test.InstrumentationRegistry; +import android.test.AndroidTestCase; +import android.view.View; +import android.view.ViewGroup; + +import com.facebook.infer.annotation.Assertions; +import com.facebook.react.bridge.BaseJavaModule; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.CatalystInstanceImpl; +import com.facebook.react.bridge.LifecycleEventListener; +import com.facebook.react.bridge.SoftAssertions; +import com.facebook.react.bridge.UiThreadUtil; +import com.facebook.react.common.futures.SimpleSettableFuture; +import com.facebook.react.modules.core.Timing; + +/** + * Use this class for writing integration tests of catalyst. This class will run all JNI call + * within separate android looper, thus you don't need to care about starting your own looper. + * + * Keep in mind that all JS remote method calls and script load calls are asynchronous and you + * should not expect them to return results immediately. + * + * In order to write catalyst integration: + * 1) Make {@link ReactIntegrationTestCase} a base class of your test case + * 2) Use {@link ReactIntegrationTestCase.ReactTestInstanceBuilder} + * instead of {@link com.facebook.react.bridge.CatalystInstanceImpl.Builder} to build catalyst + * instance for testing purposes + * + */ +public abstract class ReactIntegrationTestCase extends AndroidTestCase { + + private static final long SETUP_TIMEOUT_MS = 5000; + private static final long IDLE_TIMEOUT_MS = 15000; + + private @Nullable CatalystInstanceImpl mInstance; + private @Nullable ReactBridgeIdleSignaler mBridgeIdleSignaler; + private @Nullable ReactApplicationContext mReactContext; + + @Override + public ReactApplicationContext getContext() { + if (mReactContext == null) { + mReactContext = new ReactApplicationContext(super.getContext()); + Assertions.assertNotNull(mReactContext); + } + + return mReactContext; + } + + public void shutDownContext() { + if (mInstance != null) { + final ReactContext contextToDestroy = mReactContext; + mReactContext = null; + mInstance = null; + + UiThreadUtil.runOnUiThread(new Runnable() { + @Override + public void run() { + if (contextToDestroy != null) { + contextToDestroy.onDestroy(); + } + } + }); + } + } + + /** + * This method isn't safe since it doesn't factor in layout-only view removal. Use + * {@link #getViewByTestId} instead. + */ + @Deprecated + public T getViewAtPath(ViewGroup rootView, int... path) { + return ReactTestHelper.getViewAtPath(rootView, path); + } + + public T getViewByTestId(ViewGroup rootView, String testID) { + return (T) ReactTestHelper.getViewWithReactTestId(rootView, testID); + } + + public static class Event { + private final CountDownLatch mLatch; + + public Event() { + this(1); + } + + public Event(int counter) { + mLatch = new CountDownLatch(counter); + } + + public void occur() { + mLatch.countDown(); + } + + public boolean didOccur() { + return mLatch.getCount() == 0; + } + + public boolean await(long millis) { + try { + return mLatch.await(millis, TimeUnit.MILLISECONDS); + } catch (InterruptedException ignore) { + return false; + } + } + } + + /** + * Timing module needs to be created on the main thread so that it gets the correct Choreographer. + */ + protected Timing createTimingModule() { + final SimpleSettableFuture simpleSettableFuture = new SimpleSettableFuture(); + UiThreadUtil.runOnUiThread( + new Runnable() { + @Override + public void run() { + Timing timing = new Timing(getContext()); + simpleSettableFuture.set(timing); + } + }); + try { + return simpleSettableFuture.get(5000, TimeUnit.MILLISECONDS); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public class ReactTestInstanceBuilder extends CatalystInstanceImpl.Builder { + + @Override + public CatalystInstanceImpl build() { + // Call build in separate looper and wait for it to finish before returning + final Event setupEvent = new Event(); + UiThreadUtil.runOnUiThread( + new Runnable() { + @Override + public void run() { + mInstance = ReactTestInstanceBuilder.super.build(); + mBridgeIdleSignaler = new ReactBridgeIdleSignaler(); + mInstance.addBridgeIdleDebugListener(mBridgeIdleSignaler); + getContext().initializeWithInstance(mInstance); + setupEvent.occur(); + } + }); + + if (!setupEvent.await(SETUP_TIMEOUT_MS)) { + throw new RuntimeException( + "Instance setup should take less than " + SETUP_TIMEOUT_MS + "ms"); + } + return mInstance; + } + } + + public boolean waitForBridgeIdle(long millis) { + return Assertions.assertNotNull(mBridgeIdleSignaler).waitForIdle(millis); + } + + public void waitForIdleSync() { + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + } + + public void waitForBridgeAndUIIdle() { + ReactIdleDetectionUtil.waitForBridgeAndUIIdle( + Assertions.assertNotNull(mBridgeIdleSignaler), + getContext(), + IDLE_TIMEOUT_MS); + } + + @Override + protected void tearDown() throws Exception { + super.tearDown(); + shutDownContext(); + } + + protected static void initializeJavaModule(final BaseJavaModule javaModule) { + final Semaphore semaphore = new Semaphore(0); + UiThreadUtil.runOnUiThread( + new Runnable() { + @Override + public void run() { + javaModule.initialize(); + if (javaModule instanceof LifecycleEventListener) { + ((LifecycleEventListener) javaModule).onHostResume(); + } + semaphore.release(); + } + }); + try { + SoftAssertions.assertCondition( + semaphore.tryAcquire(5000, TimeUnit.MILLISECONDS), + "Timed out initializing timing module"); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } +} diff --git a/ReactAndroid/src/androidTest/java/com/facebook/react/testing/ReactSettingsForTests.java b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/ReactSettingsForTests.java new file mode 100644 index 000000000..2b6ae8057 --- /dev/null +++ b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/ReactSettingsForTests.java @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. + * All rights reserved. + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.testing; + +import com.facebook.react.modules.debug.DeveloperSettings; + +/** + * Default ReactSettings for tests. + */ +public class ReactSettingsForTests implements DeveloperSettings { + + @Override + public boolean isFpsDebugEnabled() { + return false; + } + + @Override + public boolean isAnimationFpsDebugEnabled() { + return false; + } + + @Override + public boolean isJSDevModeEnabled() { + return true; + } +} diff --git a/ReactAndroid/src/androidTest/java/com/facebook/react/testing/ReactTestHelper.java b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/ReactTestHelper.java new file mode 100644 index 000000000..5b9949ade --- /dev/null +++ b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/ReactTestHelper.java @@ -0,0 +1,129 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. + * All rights reserved. + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.testing; + +import android.view.View; +import android.view.ViewGroup; + +import com.facebook.react.bridge.CatalystInstanceImpl; +import com.facebook.react.bridge.JSBundleLoader; +import com.facebook.react.bridge.NativeModuleCallExceptionHandler; +import com.facebook.react.bridge.JSCJavaScriptExecutor; +import com.facebook.react.bridge.NativeModule; +import com.facebook.react.bridge.NativeModuleRegistry; +import com.facebook.react.bridge.JavaScriptModulesConfig; +import com.facebook.react.bridge.queue.CatalystQueueConfigurationSpec; + +import com.android.internal.util.Predicate; + +public class ReactTestHelper { + + public static class ReactInstanceEasyBuilder { + + private final ReactIntegrationTestCase mTestCase; + private final NativeModuleRegistry.Builder mNativeModuleRegistryBuilder; + private final JavaScriptModulesConfig.Builder mJSModulesConfigBuilder; + + private ReactInstanceEasyBuilder(ReactIntegrationTestCase testCase) { + mTestCase = testCase; + mNativeModuleRegistryBuilder = new NativeModuleRegistry.Builder(); + mJSModulesConfigBuilder = new JavaScriptModulesConfig.Builder(); + } + + public CatalystInstanceImpl build() { + CatalystInstanceImpl instance = mTestCase.new ReactTestInstanceBuilder() + .setCatalystQueueConfigurationSpec(CatalystQueueConfigurationSpec.createDefault()) + .setJSExecutor(new JSCJavaScriptExecutor()) + .setRegistry(mNativeModuleRegistryBuilder.build()) + .setJSModulesConfig(mJSModulesConfigBuilder.build()) + .setJSBundleLoader(JSBundleLoader.createFileLoader( + mTestCase.getContext(), + "assets://AndroidTestBundle.js")) + .setNativeModuleCallExceptionHandler( + new NativeModuleCallExceptionHandler() { + @Override + public void handleException(Exception e) { + throw new RuntimeException(e); + } + }) + .build(); + instance.runJSBundle(); + mTestCase.waitForBridgeAndUIIdle(); + return instance; + } + + public ReactInstanceEasyBuilder addNativeModule(NativeModule module) { + mNativeModuleRegistryBuilder.add(module); + return this; + } + + public ReactInstanceEasyBuilder addJSModule(Class moduleInterfaceClass) { + mJSModulesConfigBuilder.add(moduleInterfaceClass); + return this; + } + } + + public static ReactInstanceEasyBuilder catalystInstanceBuilder( + ReactIntegrationTestCase testCase) { + return new ReactInstanceEasyBuilder(testCase); + } + + /** + * Gets the view at given path in the UI hierarchy, ignoring modals. + */ + public static T getViewAtPath(ViewGroup rootView, int... path) { + // The application root element is wrapped in a helper view in order + // to be able to display modals. See renderApplication.js. + ViewGroup appWrapperView = rootView; + View view = appWrapperView.getChildAt(0); + for (int i = 0; i < path.length; i++) { + view = ((ViewGroup) view).getChildAt(path[i]); + } + return (T) view; + } + + /** + * Gets the view with a given react test ID in the UI hierarchy. React test ID is currently + * propagated into view content description. + */ + public static View getViewWithReactTestId(View rootView, String testId) { + return findChild(rootView, hasTagValue(testId)); + } + + public static String getTestId(View view) { + return view.getTag() instanceof String ? (String) view.getTag() : null; + } + + private static View findChild(View root, Predicate predicate) { + if (predicate.apply(root)) { + return root; + } + if (root instanceof ViewGroup) { + ViewGroup viewGroup = (ViewGroup) root; + for (int i = 0; i < viewGroup.getChildCount(); i++) { + View child = viewGroup.getChildAt(i); + View result = findChild(child, predicate); + if (result != null) { + return result; + } + } + } + return null; + } + + private static Predicate hasTagValue(final String tagValue) { + return new Predicate() { + @Override + public boolean apply(View view) { + Object tag = view.getTag(); + return tag != null && tag.equals(tagValue); + } + }; + } +} diff --git a/ReactAndroid/src/androidTest/java/com/facebook/react/testing/ScreenshotingFrameLayout.java b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/ScreenshotingFrameLayout.java new file mode 100644 index 000000000..ffc648e79 --- /dev/null +++ b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/ScreenshotingFrameLayout.java @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. + * All rights reserved. + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.testing; + +import javax.annotation.Nullable; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.os.Looper; +import android.widget.FrameLayout; + +/** + * A FrameLayout that allows you to access the result of the last time its hierarchy was drawn. It + * accomplishes this by drawing its hierarchy into a software Canvas, saving the resulting Bitmap + * and then drawing that Bitmap to the actual Canvas provided by the system. + */ +public class ScreenshotingFrameLayout extends FrameLayout { + + private @Nullable Bitmap mBitmap; + private Canvas mCanvas; + + public ScreenshotingFrameLayout(Context context) { + super(context); + mCanvas = new Canvas(); + } + + @Override + protected void dispatchDraw(Canvas canvas) { + if (mBitmap == null) { + mBitmap = createNewBitmap(canvas); + mCanvas.setBitmap(mBitmap); + } else if (mBitmap.getWidth() != canvas.getWidth() || + mBitmap.getHeight() != canvas.getHeight()) { + mBitmap.recycle(); + mBitmap = createNewBitmap(canvas); + mCanvas.setBitmap(mBitmap); + } + + super.dispatchDraw(mCanvas); + canvas.drawBitmap(mBitmap, 0, 0, null); + } + + private static Bitmap createNewBitmap(Canvas canvas) { + return Bitmap.createBitmap(canvas.getWidth(), canvas.getHeight(), Bitmap.Config.ARGB_8888); + } + + public Bitmap getLastDrawnBitmap() { + if (mBitmap == null) { + throw new RuntimeException("View has not been drawn yet!"); + } + if (Looper.getMainLooper() != Looper.myLooper()) { + throw new RuntimeException( + "Must access screenshots from main thread or you may get partially drawn Bitmaps"); + } + if (!isScreenshotReady()) { + throw new RuntimeException("Trying to get screenshot, but the view is dirty or needs layout"); + } + return Bitmap.createBitmap(mBitmap); + } + + public boolean isScreenshotReady() { + return !isDirty() && !isLayoutRequested(); + } +} diff --git a/ReactAndroid/src/androidTest/java/com/facebook/react/testing/SingleTouchGestureGenerator.java b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/SingleTouchGestureGenerator.java new file mode 100644 index 000000000..278facbcb --- /dev/null +++ b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/SingleTouchGestureGenerator.java @@ -0,0 +1,161 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. + * All rights reserved. + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.testing; + +import android.os.SystemClock; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewConfiguration; + +/** + * Provides methods for generating touch events and dispatching them directly to a given view. + * Events scenarios are based on {@link android.test.TouchUtils} but they get gets dispatched + * directly through the view hierarchy using {@link View#dispatchTouchEvent} method instead of + * using instrumentation API. + *

+ * All the events for a gesture are dispatched immediately which makes tests run very fast. + * The eventTime for each event is still set correctly. Android's gesture recognizers check + * eventTime in order to figure out gesture speed, and therefore scroll vs fling is recognized. + */ +public class SingleTouchGestureGenerator { + + private static final long DEFAULT_DELAY_MS = 20; + + private View mDispatcherView; + private IdleWaiter mIdleWaiter; + private long mLastDownTime; + private long mEventTime; + private float mLastX; + private float mLastY; + + private ViewConfiguration mViewConfig; + + public SingleTouchGestureGenerator(View view, IdleWaiter idleWaiter) { + mDispatcherView = view; + mIdleWaiter = idleWaiter; + mViewConfig = ViewConfiguration.get(view.getContext()); + } + + private SingleTouchGestureGenerator dispatchEvent( + final int action, + final float x, + final float y, + long eventTime) { + mEventTime = eventTime; + if (action == MotionEvent.ACTION_DOWN) { + mLastDownTime = eventTime; + } + mLastX = x; + mLastY = y; + mDispatcherView.post( + new Runnable() { + @Override + public void run() { + MotionEvent event = MotionEvent.obtain(mLastDownTime, mEventTime, action, x, y, 0); + mDispatcherView.dispatchTouchEvent(event); + event.recycle(); + } + }); + mIdleWaiter.waitForBridgeAndUIIdle(); + return this; + } + + private float getViewCenterX(View view) { + int[] xy = new int[2]; + view.getLocationOnScreen(xy); + int viewWidth = view.getWidth(); + return xy[0] + (viewWidth / 2.0f); + } + + private float getViewCenterY(View view) { + int[] xy = new int[2]; + view.getLocationOnScreen(xy); + int viewHeight = view.getHeight(); + return xy[1] + (viewHeight / 2.0f); + } + + public SingleTouchGestureGenerator startGesture(float x, float y) { + return dispatchEvent(MotionEvent.ACTION_DOWN, x, y, SystemClock.uptimeMillis()); + } + + public SingleTouchGestureGenerator startGesture(View view) { + return startGesture(getViewCenterX(view), getViewCenterY(view)); + } + + private SingleTouchGestureGenerator dispatchDelayedEvent( + int action, + float x, + float y, + long delay) { + return dispatchEvent(action, x, y, mEventTime + delay); + } + + public SingleTouchGestureGenerator endGesture(float x, float y, long delay) { + return dispatchDelayedEvent(MotionEvent.ACTION_UP, x, y, delay); + } + + public SingleTouchGestureGenerator endGesture(float x, float y) { + return endGesture(x, y, DEFAULT_DELAY_MS); + } + + public SingleTouchGestureGenerator endGesture() { + return endGesture(mLastX, mLastY); + } + + public SingleTouchGestureGenerator moveGesture(float x, float y, long delay) { + return dispatchDelayedEvent(MotionEvent.ACTION_MOVE, x, y, delay); + } + + public SingleTouchGestureGenerator moveBy(float dx, float dy, long delay) { + return moveGesture(mLastX + dx, mLastY + dy, delay); + } + + public SingleTouchGestureGenerator moveBy(float dx, float dy) { + return moveBy(dx, dy, DEFAULT_DELAY_MS); + } + + public SingleTouchGestureGenerator clickViewAt(float x, float y) { + float touchSlop = mViewConfig.getScaledTouchSlop(); + return startGesture(x, y).moveBy(touchSlop / 2.0f, touchSlop / 2.0f).endGesture(); + } + + public SingleTouchGestureGenerator drag( + float fromX, + float fromY, + float toX, + float toY, + int stepCount, + long totalDelay) { + + float xStep = (toX - fromX) / stepCount; + float yStep = (toY - fromY) / stepCount; + + float x = fromX; + float y = fromY; + + for (int i = 0; i < stepCount; i++) { + x += xStep; + y += yStep; + moveGesture(x, y, totalDelay / stepCount); + } + return this; + } + + public SingleTouchGestureGenerator dragTo(float toX, float toY, int stepCount, long totalDelay) { + return drag(mLastX, mLastY, toX, toY, stepCount, totalDelay); + } + + public SingleTouchGestureGenerator dragTo(View view, int stepCount, long totalDelay) { + return dragTo(getViewCenterX(view), getViewCenterY(view), stepCount, totalDelay); + } + + public SingleTouchGestureGenerator dragTo(View view, int stepCount) { + return dragTo(view, stepCount, stepCount * DEFAULT_DELAY_MS); + } +} diff --git a/ReactAndroid/src/androidTest/java/com/facebook/react/testing/StringRecordingModule.java b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/StringRecordingModule.java new file mode 100644 index 000000000..8caa3b8f7 --- /dev/null +++ b/ReactAndroid/src/androidTest/java/com/facebook/react/testing/StringRecordingModule.java @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. + * All rights reserved. + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.testing; + +import java.util.ArrayList; +import java.util.List; + +import com.facebook.react.bridge.BaseJavaModule; +import com.facebook.react.bridge.ReactMethod; + +/** + * Native module provides single method {@link #record} which records its single string argument + * in calls array + */ +public class StringRecordingModule extends BaseJavaModule { + + private final List mCalls = new ArrayList(); + + @Override + public String getName() { + return "Recording"; + } + + @ReactMethod + public void record(String text) { + mCalls.add(text); + } + + public void reset() { + mCalls.clear(); + } + + public List getCalls() { + return mCalls; + } +} diff --git a/ReactAndroid/src/androidTest/java/com/facebook/react/tests/ReactHorizontalScrollViewTestCase.java b/ReactAndroid/src/androidTest/java/com/facebook/react/tests/ReactHorizontalScrollViewTestCase.java new file mode 100644 index 000000000..963442d47 --- /dev/null +++ b/ReactAndroid/src/androidTest/java/com/facebook/react/tests/ReactHorizontalScrollViewTestCase.java @@ -0,0 +1,134 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. + * All rights reserved. + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.tests; + +import java.util.ArrayList; + +import android.view.View; +import android.widget.HorizontalScrollView; + +import com.facebook.react.testing.AbstractScrollViewTestCase; +import com.facebook.react.testing.SingleTouchGestureGenerator; +import com.facebook.react.uimanager.PixelUtil; + +/** + * Integration test for horizontal ScrollView. + * See ScrollViewTestModule.js + */ +public class ReactHorizontalScrollViewTestCase extends AbstractScrollViewTestCase { + + @Override + protected String getReactApplicationKeyUnderTest() { + return "HorizontalScrollViewTestApp"; + } + + private void dragLeft() { + dragLeft(200); + } + + private void dragLeft(int durationMs) { + createGestureGenerator() + .startGesture(150, 50) + .dragTo(50, 60, 10, durationMs) + .endGesture(50, 60); + } + + public void testScrolling() { + HorizontalScrollView scrollView = getViewAtPath(0); + assertNotNull(scrollView); + assertEquals(0, scrollView.getScrollX()); + + dragLeft(); + + assertTrue("Expected to scroll by at least 50 pixels", scrollView.getScrollX() >= 50); + } + + public void testScrollEvents() { + HorizontalScrollView scrollView = getViewAtPath(0); + + dragLeft(); + + waitForBridgeAndUIIdle(); + mScrollListenerModule.waitForScrollIdle(); + waitForBridgeAndUIIdle(); + + ArrayList xOffsets = mScrollListenerModule.getXOffsets(); + assertFalse("Expected to receive at least one scroll event", xOffsets.isEmpty()); + assertTrue("Expected offset to be greater than 0", xOffsets.get(xOffsets.size() - 1) > 0); + assertTrue( + "Expected no item click event fired", + mScrollListenerModule.getItemsPressed().isEmpty()); + assertEquals( + "Expected last offset to be offset of scroll view", + PixelUtil.toDIPFromPixel(scrollView.getScrollX()), + xOffsets.get(xOffsets.size() - 1).doubleValue(), + 1e-5); + } + + public void testScrollAndClick() throws Exception { + SingleTouchGestureGenerator gestureGenerator = createGestureGenerator(); + + // Slowly drag the ScrollView to prevent fling + dragLeft(15000); + + waitForBridgeAndUIIdle(); + getInstrumentation().waitForIdleSync(); + + // Find visible item to be clicked + View visibleItem = null; + int visibleItemNumber = 0; + for (; visibleItemNumber < 100; visibleItemNumber++) { + visibleItem = getViewAtPath(0, 0, visibleItemNumber); + int pos[] = new int[2]; + visibleItem.getLocationInWindow(pos); + if (pos[0] >= 0) { + break; + } + } + + // Click first visible item + gestureGenerator.startGesture(visibleItem).endGesture(); + waitForBridgeAndUIIdle(); + + ArrayList xOffsets = mScrollListenerModule.getXOffsets(); + ArrayList itemIds = mScrollListenerModule.getItemsPressed(); + assertFalse("Expected to receive at least one scroll event", xOffsets.isEmpty()); + assertTrue("Expected offset to be greater than 0", xOffsets.get(xOffsets.size() - 1) > 0); + assertEquals("Expected to receive exactly one item click event", 1, itemIds.size()); + assertEquals(visibleItemNumber, (int) itemIds.get(0)); + } + + /** + * Verify that 'scrollTo' command makes ScrollView start scrolling + */ + public void testScrollToCommand() throws Exception { + HorizontalScrollView scrollView = getViewAtPath(0); + ScrollViewTestModule jsModule = + getReactContext().getCatalystInstance().getJSModule(ScrollViewTestModule.class); + + assertEquals(0, scrollView.getScrollX()); + + jsModule.scrollTo(300, 0); + waitForBridgeAndUIIdle(); + getInstrumentation().waitForIdleSync(); + + // Unfortunately we need to use timeouts here in order to wait for scroll animation to happen + // there is no better way (yet) for waiting for scroll animation to finish + long timeout = 10000; + long interval = 50; + long start = System.currentTimeMillis(); + while (System.currentTimeMillis() - start < timeout) { + if (scrollView.getScrollX() > 0) { + break; + } + Thread.sleep(interval); + } + assertNotSame(0, scrollView.getScrollX()); + } +} diff --git a/ReactAndroid/src/androidTest/java/com/facebook/react/tests/ReactScrollViewTestCase.java b/ReactAndroid/src/androidTest/java/com/facebook/react/tests/ReactScrollViewTestCase.java new file mode 100644 index 000000000..45a47a673 --- /dev/null +++ b/ReactAndroid/src/androidTest/java/com/facebook/react/tests/ReactScrollViewTestCase.java @@ -0,0 +1,136 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. + * All rights reserved. + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.tests; + +import java.util.ArrayList; + +import android.view.View; +import android.widget.ScrollView; + +import com.facebook.react.testing.AbstractScrollViewTestCase; +import com.facebook.react.testing.SingleTouchGestureGenerator; +import com.facebook.react.uimanager.PixelUtil; + +/** + * Integration test for vertical ScrollView. + * See ScrollViewTestModule.js + */ +public class ReactScrollViewTestCase extends AbstractScrollViewTestCase { + + @Override + protected String getReactApplicationKeyUnderTest() { + return "ScrollViewTestApp"; + } + + private void dragUp() { + dragUp(200); + } + + private void dragUp(int durationMs) { + createGestureGenerator() + .startGesture(200, 200) + .dragTo(180, 100, 10, durationMs) + .endGesture(180, 100); + } + + public void testScrolling() { + ScrollView scrollView = getViewAtPath(0); + assertNotNull(scrollView); + assertEquals(0, scrollView.getScrollY()); + + dragUp(); + + assertTrue("Expected to scroll by at least 50 pixels", scrollView.getScrollY() >= 50); + } + + public void testScrollEvents() { + ScrollView scrollView = getViewAtPath(0); + + dragUp(); + + waitForBridgeAndUIIdle(); + mScrollListenerModule.waitForScrollIdle(); + waitForBridgeAndUIIdle(); + + ArrayList yOffsets = mScrollListenerModule.getYOffsets(); + assertFalse("Expected to receive at least one scroll event", yOffsets.isEmpty()); + assertTrue("Expected offset to be greater than 0", yOffsets.get(yOffsets.size() - 1) > 0); + assertTrue( + "Expected no item click event fired", + mScrollListenerModule.getItemsPressed().isEmpty()); + assertEquals( + "Expected last offset to be offset of scroll view", + PixelUtil.toDIPFromPixel(scrollView.getScrollY()), + yOffsets.get(yOffsets.size() - 1).doubleValue(), + 1e-5); + assertTrue("Begin and End Drag should be called", mScrollListenerModule.dragEventsMatch()); + } + + public void testScrollAndClick() throws Exception { + SingleTouchGestureGenerator gestureGenerator = createGestureGenerator(); + + // Slowly drag the ScrollView to prevent fling + dragUp(15000); + + waitForBridgeAndUIIdle(); + getInstrumentation().waitForIdleSync(); + + // Find visible item to be clicked + View visibleItem = null; + int visibleItemNumber = 0; + for (; visibleItemNumber < 100; visibleItemNumber++) { + visibleItem = getViewAtPath(0, 0, visibleItemNumber); + int pos[] = new int[2]; + visibleItem.getLocationInWindow(pos); + if (pos[1] >= 0) { + break; + } + } + + // Click first visible item + gestureGenerator.startGesture(visibleItem).endGesture(); + waitForBridgeAndUIIdle(); + + ArrayList yOffsets = mScrollListenerModule.getYOffsets(); + ArrayList itemIds = mScrollListenerModule.getItemsPressed(); + assertFalse("Expected to receive at least one scroll event", yOffsets.isEmpty()); + assertTrue("Expected offset to be greater than 0", yOffsets.get(yOffsets.size() - 1) > 0); + assertEquals("Expected to receive exactly one item click event", 1, itemIds.size()); + assertEquals(visibleItemNumber, (int) itemIds.get(0)); + } + + /** + * Verify that 'scrollTo' command makes ScrollView start scrolling + */ + public void testScrollToCommand() throws Exception { + ScrollView scrollView = getViewAtPath(0); + ScrollViewTestModule jsModule = + getReactContext().getCatalystInstance().getJSModule(ScrollViewTestModule.class); + + assertEquals(0, scrollView.getScrollY()); + + jsModule.scrollTo(0, 300); + waitForBridgeAndUIIdle(); + getInstrumentation().waitForIdleSync(); + + // Unfortunately we need to use timeouts here in order to wait for scroll animation to happen + // there is no better way (yet) for waiting for scroll animation to finish + long timeout = 10000; + long interval = 50; + long start = System.currentTimeMillis(); + while (System.currentTimeMillis() - start < timeout) { + if (scrollView.getScrollY() > 0) { + break; + } + Thread.sleep(interval); + } + assertNotSame(0, scrollView.getScrollY()); + assertFalse("Drag should not be called with scrollTo", mScrollListenerModule.dragEventsMatch()); + } +} diff --git a/circle.yml b/circle.yml index 51feb2c00..ff3c01145 100644 --- a/circle.yml +++ b/circle.yml @@ -9,4 +9,8 @@ dependencies: test: override: # gradle is flaky in CI envs, found a solution here http://stackoverflow.com/questions/28409608/gradle-assembledebug-and-predexdebug-fail-with-circleci - - TERM=dumb ./gradlew cleanTest test -PpreDexEnable=false -Pcom.android.build.threadPoolSize=1 -Dorg.gradle.parallel=false -Dorg.gradle.jvmargs="-Xms512m -Xmx512m" -Dorg.gradle.daemon=false + - TERM=dumb ./gradlew testDebugUnitTest -PpreDexEnable=false -Pcom.android.build.threadPoolSize=1 -Dorg.gradle.parallel=false -Dorg.gradle.jvmargs="-Xms512m -Xmx512m" -Dorg.gradle.daemon=false + # build JS bundle + - node local-cli/cli.js bundle --platform android --dev true --entry-file ReactAndroid/src/androidTest/assets/TestBundle.js --bundle-output ReactAndroid/src/androidTest/assets/AndroidTestBundle.js + # run instrumentation tests on device + - TERM=dumb ./gradlew connectedCheck