diff options
author | Nick Korostelev <nkorsote@google.com> | 2014-06-17 22:46:55 +0000 |
---|---|---|
committer | Android (Google) Code Review <android-gerrit@google.com> | 2014-06-17 20:42:58 +0000 |
commit | 69848b5928ae5eabac7e13e5f9ccb6e31ad3bde7 (patch) | |
tree | a3504ffa401fd0dc40e0a157e479eba8d3b8842f | |
parent | 63726971b42c7a6fb9df9d70117ad10511da96e3 (diff) | |
parent | f69eb9ac2856f470cb79f57141f711ed3ceed99d (diff) | |
download | testing-69848b5928ae5eabac7e13e5f9ccb6e31ad3bde7.tar.gz |
Merge "port Espresso to Android repo"
222 files changed, 20069 insertions, 0 deletions
diff --git a/espresso/build.gradle b/espresso/build.gradle new file mode 100644 index 0000000..c2d71cb --- /dev/null +++ b/espresso/build.gradle @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +buildscript { + repositories { + maven { url '../../../prebuilts/gradle-plugin' } + maven { url '../../../prebuilts/tools/common/m2/repository' } + maven { url '../../../prebuilts/tools/common/m2/internal' } + } + dependencies { + classpath 'com.android.tools.build:gradle:0.10.+' + } +} + +subprojects { + project.ext { + androidSdkPath = getAndroidSdkPath() + println 'Using Android SDK at: ' + androidSdkPath + } +} + +def getAndroidSdkPath() { + if (project.has("androidCustomSdkPath")) { + project.androidCustomSdkPath + } else { + System.getenv("ANDROID_HOME") + } +} diff --git a/espresso/espresso-contrib-tests/build.gradle b/espresso/espresso-contrib-tests/build.gradle new file mode 100644 index 0000000..e41b8aa --- /dev/null +++ b/espresso/espresso-contrib-tests/build.gradle @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +apply plugin: 'android' + +repositories { + maven { url '../../../../prebuilts/tools/common/m2/repository' } + maven { url '../../../../prebuilts/tools/common/m2/internal' } +} + +android { + compileSdkVersion 19 + buildToolsVersion "19.0.3" + + packagingOptions { + exclude 'LICENSE.txt' + } + + lintOptions { + abortOnError false + } + + defaultConfig { + testInstrumentationRunner "com.google.android.apps.common.testing.testrunner.GoogleInstrumentationTestRunner" + } + + sourceSets { + // Setting espresso-sample as the main root of this project to avoid source code duplication. + // Temporary workaround until Android Gradle plugin supports settings custom target package + // for Android Tests. + main.setRoot("../espresso-sample/src/main") + } +} + +dependencies { + compile files('../libs/guava-14.0.1.jar') + compile 'com.android.support:support-v4:19.1.+' + compile 'com.android.support:appcompat-v7:19.1.+' + + androidTestCompile project(':espresso-contrib') +} diff --git a/espresso/espresso-contrib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/contrib/DrawerActionsIntegrationTest.java b/espresso/espresso-contrib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/contrib/DrawerActionsIntegrationTest.java new file mode 100644 index 0000000..0e1ee59 --- /dev/null +++ b/espresso/espresso-contrib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/contrib/DrawerActionsIntegrationTest.java @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.contrib; + +import static com.google.android.apps.common.testing.ui.espresso.Espresso.onData; +import static com.google.android.apps.common.testing.ui.espresso.Espresso.onView; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.click; +import static com.google.android.apps.common.testing.ui.espresso.assertion.ViewAssertions.matches; +import static com.google.android.apps.common.testing.ui.espresso.contrib.DrawerActions.closeDrawer; +import static com.google.android.apps.common.testing.ui.espresso.contrib.DrawerActions.openDrawer; +import static com.google.android.apps.common.testing.ui.espresso.contrib.DrawerMatchers.isClosed; +import static com.google.android.apps.common.testing.ui.espresso.contrib.DrawerMatchers.isOpen; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withId; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; + +import com.google.android.apps.common.testing.ui.testapp.DrawerActivity; +import com.google.android.apps.common.testing.ui.testapp.R; + +import android.test.ActivityInstrumentationTestCase2; +import android.test.suitebuilder.annotation.LargeTest; + +/** + * Integration tests for {@link DrawerActions}. + */ +@LargeTest +public class DrawerActionsIntegrationTest extends ActivityInstrumentationTestCase2<DrawerActivity> { + + public DrawerActionsIntegrationTest() { + super(DrawerActivity.class); + } + + @Override + protected void setUp() throws Exception { + super.setUp(); + getActivity(); + } + + public void testOpenAndCloseDrawer() { + // Drawer should not be open to start. + onView(withId(R.id.drawer_layout)).check(matches(isClosed())); + + openDrawer(R.id.drawer_layout); + + // The drawer should now be open. + onView(withId(R.id.drawer_layout)).check(matches(isOpen())); + + closeDrawer(R.id.drawer_layout); + + // Drawer should be closed again. + onView(withId(R.id.drawer_layout)).check(matches(isClosed())); + } + + public void testOpenAndCloseDrawer_idempotent() { + // Drawer should not be open to start. + onView(withId(R.id.drawer_layout)).check(matches(isClosed())); + + // Open drawer repeatedly. + openDrawer(R.id.drawer_layout); + openDrawer(R.id.drawer_layout); + openDrawer(R.id.drawer_layout); + + // The drawer should be open. + onView(withId(R.id.drawer_layout)).check(matches(isOpen())); + + // Close drawer repeatedly. + closeDrawer(R.id.drawer_layout); + closeDrawer(R.id.drawer_layout); + closeDrawer(R.id.drawer_layout); + + // Drawer should be closed. + onView(withId(R.id.drawer_layout)).check(matches(isClosed())); + } + + @SuppressWarnings("unchecked") + public void testOpenDrawer_clickItem() { + openDrawer(R.id.drawer_layout); + + // Click an item in the drawer. + int rowIndex = 2; + String rowContents = DrawerActivity.DRAWER_CONTENTS[rowIndex]; + onData(allOf(is(instanceOf(String.class)), is(rowContents))).perform(click()); + + // clicking the item should close the drawer. + onView(withId(R.id.drawer_layout)).check(matches(isClosed())); + + // The text view will now display "You picked: Pickle" + onView(withId(R.id.drawer_text_view)).check(matches(withText("You picked: " + rowContents))); + } +} diff --git a/espresso/espresso-contrib/build.gradle b/espresso/espresso-contrib/build.gradle new file mode 100644 index 0000000..5a4d4eb --- /dev/null +++ b/espresso/espresso-contrib/build.gradle @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +apply plugin: 'android-library' + +sourceCompatibility = JavaVersion.VERSION_1_5 +targetCompatibility = JavaVersion.VERSION_1_5 + +repositories { + maven { url '../../../../prebuilts/tools/common/m2/repository' } + maven { url '../../../../prebuilts/tools/common/m2/internal' } +} + +android { + compileSdkVersion 19 + buildToolsVersion "19.0.3" + + packagingOptions { + exclude 'LICENSE.txt' + } + + lintOptions { + abortOnError false + } +} + +dependencies { + compile project(':espresso-lib') + compile 'com.android.support:support-v4:19.1.+' +} diff --git a/espresso/espresso-contrib/src/main/AndroidManifest.xml b/espresso/espresso-contrib/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1dd537b --- /dev/null +++ b/espresso/espresso-contrib/src/main/AndroidManifest.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2014 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + package="com.google.android.apps.common.testing.ui.espresso.contrib" > + + <uses-sdk + android:minSdkVersion="7"/> + + <application /> + +</manifest>
\ No newline at end of file diff --git a/espresso/espresso-contrib/src/main/java/com/google/android/apps/common/testing/ui/espresso/contrib/DrawerActions.java b/espresso/espresso-contrib/src/main/java/com/google/android/apps/common/testing/ui/espresso/contrib/DrawerActions.java new file mode 100644 index 0000000..733c94f --- /dev/null +++ b/espresso/espresso-contrib/src/main/java/com/google/android/apps/common/testing/ui/espresso/contrib/DrawerActions.java @@ -0,0 +1,275 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.contrib; +import static com.google.android.apps.common.testing.ui.espresso.Espresso.onView; +import static com.google.android.apps.common.testing.ui.espresso.contrib.DrawerMatchers.isClosed; +import static com.google.android.apps.common.testing.ui.espresso.contrib.DrawerMatchers.isOpen; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isAssignableFrom; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withId; + +import com.google.android.apps.common.testing.ui.espresso.Espresso; +import com.google.android.apps.common.testing.ui.espresso.IdlingResource; +import com.google.android.apps.common.testing.ui.espresso.PerformException; +import com.google.android.apps.common.testing.ui.espresso.UiController; +import com.google.android.apps.common.testing.ui.espresso.ViewAction; + +import android.support.v4.view.GravityCompat; +import android.support.v4.widget.DrawerLayout; +import android.support.v4.widget.DrawerLayout.DrawerListener; +import android.view.View; + +import org.hamcrest.Matcher; + +import java.lang.reflect.Field; +import java.util.concurrent.atomic.AtomicBoolean; + +import javax.annotation.Nullable; + +/** + * Espresso actions for using a {@link DrawerLayout}. + * + * @see <a href="http://developer.android.com/design/patterns/navigation-drawer.html">Navigation + * drawer design guide</a> + */ +public final class DrawerActions { + + private DrawerActions() { + // forbid instantiation + } + + private static Field listenerField; + + /** + * Opens the {@link DrawerLayout} with the given id. This method blocks until the drawer is fully + * open. No operation if the drawer is already open. + */ + public static void openDrawer(int drawerLayoutId) { + //if the drawer is already open, return. + if (checkDrawer(drawerLayoutId, isOpen())) { + return; + } + onView(withId(drawerLayoutId)).perform(registerListener()); + onView(withId(drawerLayoutId)).perform(actionOpenDrawer()); + } + + /** + * Closes the {@link DrawerLayout} with the given id. This method blocks until the drawer is fully + * closed. No operation if the drawer is already closed. + */ + public static void closeDrawer(int drawerLayoutId) { + //if the drawer is already closed, return. + if (checkDrawer(drawerLayoutId, isClosed())) { + return; + } + onView(withId(drawerLayoutId)).perform(registerListener()); + onView(withId(drawerLayoutId)).perform(actionCloseDrawer()); + } + + /** + * Returns true if the given matcher matches the drawer. + */ + private static boolean checkDrawer(int drawerLayoutId, final Matcher<View> matcher) { + final AtomicBoolean matches = new AtomicBoolean(false); + onView(withId(drawerLayoutId)).perform(new ViewAction() { + + @Override + public Matcher<View> getConstraints() { + return isAssignableFrom(DrawerLayout.class); + } + + @Override + public String getDescription() { + return "check drawer"; + } + + @Override + public void perform(UiController uiController, View view) { + matches.set(matcher.matches(view)); + } + }); + return matches.get(); + } + + private static ViewAction actionOpenDrawer() { + return new ViewAction() { + @Override + public Matcher<View> getConstraints() { + return isAssignableFrom(DrawerLayout.class); + } + + @Override + public String getDescription() { + return "open drawer"; + } + + @Override + public void perform(UiController uiController, View view) { + ((DrawerLayout) view).openDrawer(GravityCompat.START); + } + }; + } + + private static ViewAction actionCloseDrawer() { + return new ViewAction() { + @Override + public Matcher<View> getConstraints() { + return isAssignableFrom(DrawerLayout.class); + } + + @Override + public String getDescription() { + return "close drawer"; + } + + @Override + public void perform(UiController uiController, View view) { + ((DrawerLayout) view).closeDrawer(GravityCompat.START); + } + }; + } + + /** + * Returns a {@link ViewAction} that adds an {@link IdlingDrawerListener} as a drawer listener to + * the {@link DrawerLayout}. The idling drawer listener wraps any listener that already exists. + */ + private static ViewAction registerListener() { + return new ViewAction() { + @Override + public Matcher<View> getConstraints() { + return isAssignableFrom(DrawerLayout.class); + } + + @Override + public String getDescription() { + return "register idling drawer listener"; + } + + @Override + public void perform(UiController uiController, View view) { + DrawerLayout drawer = (DrawerLayout) view; + DrawerListener existingListener = getDrawerListener(drawer); + if (existingListener instanceof IdlingDrawerListener) { + // listener is already registered. No need to assign. + return; + } + drawer.setDrawerListener(IdlingDrawerListener.getInstance(existingListener)); + } + }; + } + + /** + * Pries the current {@link DrawerListener} loose from the cold dead hands of the given + * {@link DrawerLayout}. Uses reflection. + */ + @Nullable + private static DrawerListener getDrawerListener(DrawerLayout drawer) { + try { + if (listenerField == null) { + // lazy initialization of reflected field. + listenerField = DrawerLayout.class.getDeclaredField("mListener"); + listenerField.setAccessible(true); + } + return (DrawerListener) listenerField.get(drawer); + } catch (IllegalArgumentException ex) { + // Pity we can't use Java 7 multi-catch for all of these. + throw new PerformException.Builder().withCause(ex).build(); + } catch (IllegalAccessException ex) { + throw new PerformException.Builder().withCause(ex).build(); + } catch (NoSuchFieldException ex) { + throw new PerformException.Builder().withCause(ex).build(); + } catch (SecurityException ex) { + throw new PerformException.Builder().withCause(ex).build(); + } + } + + /** + * Drawer listener that wraps an existing {@link DrawerListener}, and functions as an + * {@link IdlingResource} for Espresso. + */ + private static class IdlingDrawerListener implements DrawerListener, IdlingResource { + + private static IdlingDrawerListener instance; + private static IdlingDrawerListener getInstance(DrawerListener parentListener) { + if (instance == null) { + instance = new IdlingDrawerListener(); + Espresso.registerIdlingResources(instance); + } + instance.setParentListener(parentListener); + return instance; + } + + @Nullable private DrawerListener parentListener; + private ResourceCallback callback; + // Idle state is only accessible from main thread. + private boolean idle = true; + + public void setParentListener(@Nullable DrawerListener parentListener) { + this.parentListener = parentListener; + } + + @Override + public void onDrawerClosed(View drawer) { + if (parentListener != null) { + parentListener.onDrawerClosed(drawer); + } + } + + @Override + public void onDrawerOpened(View drawer) { + if (parentListener != null) { + parentListener.onDrawerOpened(drawer); + } + } + + @Override + public void onDrawerSlide(View drawer, float slideOffset) { + if (parentListener != null) { + parentListener.onDrawerSlide(drawer, slideOffset); + } + } + + @Override + public void onDrawerStateChanged(int newState) { + if (newState == DrawerLayout.STATE_IDLE) { + idle = true; + if (callback != null) { + callback.onTransitionToIdle(); + } + } else { + idle = false; + } + if (parentListener != null) { + parentListener.onDrawerStateChanged(newState); + } + } + + @Override + public String getName() { + return "IdlingDrawerListener"; + } + + @Override + public boolean isIdleNow() { + return idle; + } + + @Override + public void registerIdleTransitionCallback(ResourceCallback callback) { + this.callback = callback; + } + } +} diff --git a/espresso/espresso-contrib/src/main/java/com/google/android/apps/common/testing/ui/espresso/contrib/DrawerMatchers.java b/espresso/espresso-contrib/src/main/java/com/google/android/apps/common/testing/ui/espresso/contrib/DrawerMatchers.java new file mode 100644 index 0000000..ca66af8 --- /dev/null +++ b/espresso/espresso-contrib/src/main/java/com/google/android/apps/common/testing/ui/espresso/contrib/DrawerMatchers.java @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.contrib; + +import com.google.android.apps.common.testing.ui.espresso.matcher.BoundedMatcher; + +import android.support.v4.view.GravityCompat; +import android.support.v4.widget.DrawerLayout; +import android.view.View; + +import org.hamcrest.Description; +import org.hamcrest.Matcher; + +/** + * Hamcrest matchers for a {@link DrawerLayout}. + */ +public final class DrawerMatchers { + + private DrawerMatchers() { + // forbid instantiation + } + + /** + * Returns a matcher that verifies that the drawer is open. Matches only when the drawer is fully + * open. Use {@link #isClosed()} instead of {@code not(isOpen())} when you wish to check that the + * drawer is fully closed. + */ + public static Matcher<View> isOpen() { + return new BoundedMatcher<View, DrawerLayout>(DrawerLayout.class) { + @Override + public void describeTo(Description description) { + description.appendText("is drawer open"); + } + + @Override + public boolean matchesSafely(DrawerLayout drawer) { + return drawer.isDrawerOpen(GravityCompat.START); + } + }; + } + + /** + * Returns a matcher that verifies that the drawer is closed. Matches only when the drawer is + * fully closed. Use {@link #isOpen()} instead of {@code not(isClosed()))} when you wish to check + * that the drawer is fully open. + */ + public static Matcher<View> isClosed() { + return new BoundedMatcher<View, DrawerLayout>(DrawerLayout.class) { + @Override + public void describeTo(Description description) { + description.appendText("is drawer closed"); + } + + @Override + public boolean matchesSafely(DrawerLayout drawer) { + return !drawer.isDrawerVisible(GravityCompat.START); + } + }; + } +} diff --git a/espresso/espresso-lib-tests/build.gradle b/espresso/espresso-lib-tests/build.gradle new file mode 100644 index 0000000..ce3faa7 --- /dev/null +++ b/espresso/espresso-lib-tests/build.gradle @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +apply plugin: 'android' + +repositories { + maven { url '../../../../prebuilts/tools/common/m2/repository' } + maven { url '../../../../prebuilts/tools/common/m2/internal' } +} + +android { + compileSdkVersion 19 + buildToolsVersion "19.0.3" + + packagingOptions { + exclude 'LICENSE.txt' + } + + lintOptions { + abortOnError false + } + + defaultConfig { + testInstrumentationRunner "com.google.android.apps.common.testing.testrunner.GoogleInstrumentationTestRunner" + } + + sourceSets { + // Setting espresso-sample as the main root of this project to avoid source code duplication. + // Temporary workaround until Android Gradle plugin supports settings custom target package + // for Android Tests. + main.setRoot("../espresso-sample/src/main") + } +} + +dependencies { + compile files('../libs/guava-14.0.1.jar') + compile 'com.android.support:support-v4:19.1.+' + compile 'com.android.support:appcompat-v7:19.1.+' + + // run test against an un-jarjared variant of the lib + androidTestCompile project(path: ':espresso-lib', configuration: 'debug') + + androidTestCompile 'org.mockito:mockito-core:1.9.5' +} diff --git a/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/AmbiguousViewMatcherExceptionTest.java b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/AmbiguousViewMatcherExceptionTest.java new file mode 100644 index 0000000..650c426 --- /dev/null +++ b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/AmbiguousViewMatcherExceptionTest.java @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.containsString; + +import android.test.AndroidTestCase; +import android.view.View; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import org.hamcrest.Matcher; +import org.hamcrest.Matchers; +import org.hamcrest.StringDescription; + +/** Unit tests for {@link AmbiguousViewMatcherException}. */ +public class AmbiguousViewMatcherExceptionTest extends AndroidTestCase { + private Matcher<View> alwaysTrueMatcher; + + private RelativeLayout testView; + private View child1; + private View child2; + private View child3; + private View child4; + + @Override + public void setUp() throws Exception { + super.setUp(); + alwaysTrueMatcher = Matchers.<View>notNullValue(); + testView = new RelativeLayout(getContext()); + child1 = new TextView(getContext()); + child1.setId(1); + child2 = new TextView(getContext()); + child2.setId(2); + child3 = new TextView(getContext()); + child3.setId(3); + child4 = new TextView(getContext()); + child4.setId(4); + testView.addView(child1); + testView.addView(child2); + testView.addView(child3); + testView.addView(child4); + } + + public void testExceptionContainsMatcherDescription() { + StringBuilder matcherDescription = new StringBuilder(); + alwaysTrueMatcher.describeTo(new StringDescription(matcherDescription)); + assertThat(createException().getMessage(), containsString(matcherDescription.toString())); + } + + @SuppressWarnings("unchecked") + public void testExceptionContainsView() { + String exceptionMessage = createException().getMessage(); + + assertThat("missing elements", exceptionMessage, + allOf( + containsString("{id=1,"), // child1 + containsString("{id=2,"), // child2 + containsString("{id=3,"), // child3 + containsString("{id=4,"), // child4 + containsString("{id=-1,"))); // root + } + + private AmbiguousViewMatcherException createException() { + + return new AmbiguousViewMatcherException.Builder() + .withViewMatcher(alwaysTrueMatcher) + .withRootView(testView) + .withView1(testView) + .withView2(child1) + .withOtherAmbiguousViews(child2, child3, child4) + .build(); + } +} diff --git a/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/AppNotIdleExceptionTest.java b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/AppNotIdleExceptionTest.java new file mode 100644 index 0000000..48fe347 --- /dev/null +++ b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/AppNotIdleExceptionTest.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso; + +import static com.google.android.apps.common.testing.ui.espresso.Espresso.onView; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.click; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withId; + +import com.google.android.apps.common.testing.ui.testapp.R; +import com.google.android.apps.common.testing.ui.testapp.SyncActivity; + +import android.os.Handler; +import android.os.Looper; +import android.test.ActivityInstrumentationTestCase2; +import android.test.suitebuilder.annotation.LargeTest; + +import java.util.concurrent.FutureTask; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Test case for {@link AppNotIdleException}. + */ +@LargeTest +public class AppNotIdleExceptionTest extends ActivityInstrumentationTestCase2<SyncActivity> { + + @SuppressWarnings("deprecation") + public AppNotIdleExceptionTest() { + // This constructor was deprecated - but we want to support lower API levels. + super("com.google.android.apps.common.testing.ui.testapp", SyncActivity.class); + } + + @Override + public void setUp() throws Exception { + super.setUp(); + getActivity(); + } + + public void testAppIdleException() throws Exception { + final AtomicBoolean continueBeingBusy = new AtomicBoolean(true); + try { + final Handler handler = new Handler(Looper.getMainLooper()); + Runnable runnable = new Runnable() { + @Override + public void run() { + if (!continueBeingBusy.get()) { + return; + } else { + handler.post(this); + } + } + }; + FutureTask<Void> task = new FutureTask<Void>(runnable, null); + handler.post(task); + task.get(); // Will Make sure that the first post is sent before we do a lookup. + // Request the "hello world!" text by clicking on the request button. + onView(withId(R.id.request_button)).perform(click()); + fail("Espresso failed to throw AppNotIdleException"); + } catch (AppNotIdleException e) { + // Do Nothing. Test pass. + continueBeingBusy.getAndSet(false); + } + } +} diff --git a/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/EspressoEdgeCaseTest.java b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/EspressoEdgeCaseTest.java new file mode 100644 index 0000000..8439a96 --- /dev/null +++ b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/EspressoEdgeCaseTest.java @@ -0,0 +1,308 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso; + +import static com.google.android.apps.common.testing.ui.espresso.Espresso.onView; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.click; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.typeText; +import static com.google.android.apps.common.testing.ui.espresso.assertion.ViewAssertions.matches; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isAssignableFrom; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withId; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withText; + +import com.google.android.apps.common.testing.ui.testapp.R; +import com.google.android.apps.common.testing.ui.testapp.SendActivity; + +import android.os.AsyncTask; +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; +import android.test.ActivityInstrumentationTestCase2; +import android.test.suitebuilder.annotation.LargeTest; +import android.view.View; + +import org.hamcrest.Matcher; + +import java.util.concurrent.Callable; +import java.util.concurrent.FutureTask; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Collection of some nasty edge cases. + */ +@LargeTest +public class EspressoEdgeCaseTest extends ActivityInstrumentationTestCase2<SendActivity> { + @SuppressWarnings("deprecation") + public EspressoEdgeCaseTest() { + // Supporting froyo. + super("com.google.android.apps.common.testing.ui.testapp", SendActivity.class); + } + + private static final Callable<Void> NO_OP = new Callable<Void>() { + @Override + public Void call() { + return null; + } + }; + + private Handler mainHandler; + private OneShotResource oneShotResource; + + @Override + public void setUp() throws Exception { + super.setUp(); + getActivity(); + mainHandler = new Handler(Looper.getMainLooper()); + oneShotResource = new OneShotResource(); + } + + @Override + public void tearDown() throws Exception { + IdlingPolicies.setMasterPolicyTimeout(60, TimeUnit.SECONDS); + IdlingPolicies.setIdlingResourceTimeout(26, TimeUnit.SECONDS); + oneShotResource.setIdle(true); + super.tearDown(); + } + + @SuppressWarnings("unchecked") + public void testRecoveryFromExceptionOnMainThreadLoopMainThreadUntilIdle() throws Exception { + final RuntimeException poison = new RuntimeException("oops"); + try { + onView(withId(R.id.enter_data_edit_text)) + .perform( + new TestAction() { + + @Override + public void perform(UiController controller, View view) { + mainHandler.post(new Runnable() { + @Override + public void run() { + throw poison; + }}); + controller.loopMainThreadUntilIdle(); + } + }); + fail("should throw"); + } catch (RuntimeException re) { + if (re == poison) { + // expected + } else { + // something else. + throw re; + } + } + // life should continue normally. + onView(withId(R.id.enter_data_edit_text)) + .perform(typeText("Hello World111")); + onView(withId(R.id.enter_data_edit_text)) + .check(matches(withText("Hello World111"))); + } + + @SuppressWarnings("unchecked") + public void testRecoveryFromExceptionOnMainThreadLoopMainThreadForAtLeast() throws Exception { + final RuntimeException poison = new RuntimeException("oops"); + final FutureTask<Void> syncTask = new FutureTask<Void>(NO_OP); + try { + onView(withId(R.id.enter_data_edit_text)) + .perform( + new TestAction() { + @Override + public void perform(UiController controller, View view) { + mainHandler.post(new Runnable() { + @Override + public void run() { + throw poison; + }}); + // block test execution until loopMainThreadForAtLeast call + // would be satisified + mainHandler.postDelayed(syncTask, 2500); + controller.loopMainThreadForAtLeast(2000); + } + }); + fail("should throw"); + } catch (RuntimeException re) { + if (re == poison) { + // expected + } else { + // something else. + throw re; + } + } + syncTask.get(); + + // life should continue normally. + onView(withId(R.id.enter_data_edit_text)) + .perform(typeText("baz bar")); + onView(withId(R.id.enter_data_edit_text)) + .check(matches(withText("baz bar"))); + } + + @SuppressWarnings("unchecked") + public void testRecoveryFromTimeOutExceptionMaster() throws Exception { + IdlingPolicies.setMasterPolicyTimeout(2, TimeUnit.SECONDS); + final FutureTask<Void> syncTask = new FutureTask<Void>(NO_OP); + try { + onView(withId(R.id.enter_data_edit_text)) + .perform( + new TestAction() { + @Override + public void perform(UiController controller, View view) { + mainHandler.post(new Runnable() { + @Override + public void run() { + SystemClock.sleep(TimeUnit.SECONDS.toMillis(8)); + } + }); + // block test execution until loopMainThreadForAtLeast call + // would be satisified + mainHandler.postDelayed(syncTask, 2500); + controller.loopMainThreadForAtLeast(1000); + } + }); + fail("should throw"); + } catch (RuntimeException re) { + if (re instanceof EspressoException) { + // expected + } else { + // something else. + throw re; + } + } + syncTask.get(); + + // life should continue normally. + onView(withId(R.id.enter_data_edit_text)) + .perform(typeText("one two three")); + onView(withId(R.id.enter_data_edit_text)) + .check(matches(withText("one two three"))); + } + + @SuppressWarnings("unchecked") + public void testRecoveryFromTimeOutExceptionDynamic() { + IdlingPolicies.setIdlingResourceTimeout(2, TimeUnit.SECONDS); + + Espresso.registerIdlingResources(oneShotResource); + oneShotResource.setIdle(false); + + try { + onView(withId(R.id.enter_data_edit_text)) + .perform(click()); + fail("should throw"); + } catch (RuntimeException re) { + if (re instanceof EspressoException) { + // expected + } else { + // something else. + throw re; + } + } + oneShotResource.setIdle(true); + + // life should continue normally. + onView(withId(R.id.enter_data_edit_text)) + .perform(typeText("Doh")); + onView(withId(R.id.enter_data_edit_text)) + .check(matches(withText("Doh"))); + } + + public void testRecoveryFromAsyncTaskTimeout() throws Exception { + IdlingPolicies.setMasterPolicyTimeout(2, TimeUnit.SECONDS); + try { + onView(withId(R.id.enter_data_edit_text)) + .perform(new TestAction() { + @Override + public void perform(UiController controller, View view) { + new AsyncTask<Void, Void, Void>() { + @Override + public Void doInBackground(Void... params) { + SystemClock.sleep(TimeUnit.SECONDS.toMillis(8)); + return null; + } + }.execute(); + // block test execution until loopMainThreadForAtLeast call + // would be satisified + controller.loopMainThreadForAtLeast(1000); + } + }); + fail("should throw"); + } catch (RuntimeException re) { + if (re instanceof EspressoException) { + // expected + } else { + // something else. + throw re; + } + } + IdlingPolicies.setMasterPolicyTimeout(60, TimeUnit.SECONDS); + // life should continue normally. + onView(withId(R.id.enter_data_edit_text)) + .perform(typeText("Har Har")); + onView(withId(R.id.enter_data_edit_text)) + .check(matches(withText("Har Har"))); + } + + + + + private abstract static class TestAction implements ViewAction { + @Override + public String getDescription() { + return "A random test action."; + } + + @Override + public Matcher<View> getConstraints() { + return isAssignableFrom(View.class); + } + } + + + private static class OneShotResource implements IdlingResource { + private static AtomicInteger counter = new AtomicInteger(0); + + private final int instance; + private volatile IdlingResource.ResourceCallback callback; + private volatile boolean isIdle = true; + + private OneShotResource() { + instance = counter.incrementAndGet(); + } + + @Override + public String getName() { + return "TestOneShotResource_" + counter; + } + + public void setIdle(boolean idle) { + isIdle = idle; + if (isIdle && callback != null) { + callback.onTransitionToIdle(); + } + } + + @Override + public boolean isIdleNow() { + return isIdle; + } + + @Override + public void registerIdleTransitionCallback(IdlingResource.ResourceCallback callback) { + this.callback = callback; + } + } +} diff --git a/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/EspressoTest.java b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/EspressoTest.java new file mode 100644 index 0000000..ff4ff39 --- /dev/null +++ b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/EspressoTest.java @@ -0,0 +1,152 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso; + +import static com.google.android.apps.common.testing.ui.espresso.Espresso.onData; +import static com.google.android.apps.common.testing.ui.espresso.Espresso.onView; +import static com.google.android.apps.common.testing.ui.espresso.Espresso.openActionBarOverflowOrOptionsMenu; +import static com.google.android.apps.common.testing.ui.espresso.Espresso.openContextualActionModeOverflowMenu; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.click; +import static com.google.android.apps.common.testing.ui.espresso.assertion.ViewAssertions.matches; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withId; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.anything; +import static org.hamcrest.Matchers.hasValue; +import static org.hamcrest.Matchers.instanceOf; + +import com.google.android.apps.common.testing.ui.espresso.action.ViewActions; +import com.google.android.apps.common.testing.ui.testapp.ActionBarTestActivity; +import com.google.android.apps.common.testing.ui.testapp.MainActivity; +import com.google.android.apps.common.testing.ui.testapp.R; +import com.google.android.apps.common.testing.ui.testapp.SendActivity; + +import android.content.Context; +import android.test.ActivityInstrumentationTestCase2; +import android.test.suitebuilder.annotation.LargeTest; +import android.view.View; +import android.view.inputmethod.InputMethodManager; + +import org.hamcrest.Matcher; + +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Tests Espresso top level (i.e. ones not specific to a view) actions like pressBack and + * closeSoftKeyboard. + */ +@LargeTest +public class EspressoTest extends ActivityInstrumentationTestCase2<MainActivity> { + @SuppressWarnings("deprecation") + public EspressoTest() { + // Supporting froyo. + super("com.google.android.apps.common.testing.ui.testapp", MainActivity.class); + } + + @Override + public void setUp() throws Exception { + super.setUp(); + getActivity(); + } + + @SuppressWarnings("unchecked") + public void testOpenOverflowInActionMode() { + onData(allOf(instanceOf(Map.class), hasValue(ActionBarTestActivity.class.getSimpleName()))) + .perform(click()); + openContextualActionModeOverflowMenu(); + onView(withText("Key")) + .perform(click()); + onView(withId(R.id.text_action_bar_result)) + .check(matches(withText("Key"))); + } + + @SuppressWarnings("unchecked") + public void testOpenOverflowFromActionBar() { + onData(allOf(instanceOf(Map.class), hasValue(ActionBarTestActivity.class.getSimpleName()))) + .perform(click()); + onView(withId(R.id.hide_contextual_action_bar)) + .perform(click()); + openActionBarOverflowOrOptionsMenu(getInstrumentation().getTargetContext()); + onView(withText("World")) + .perform(click()); + onView(withId(R.id.text_action_bar_result)) + .check(matches(withText("World"))); + } + + @SuppressWarnings("unchecked") + public void testCloseSoftKeyboard() { + onData(allOf(instanceOf(Map.class), hasValue(SendActivity.class.getSimpleName()))) + .perform(click()); + + onView(withId(R.id.enter_data_edit_text)).perform(new ViewAction() { + @Override + public Matcher<View> getConstraints() { + return anything(); + } + + @Override + public void perform(UiController uiController, View view) { + InputMethodManager imm = (InputMethodManager) getInstrumentation().getTargetContext() + .getSystemService(Context.INPUT_METHOD_SERVICE); + imm.showSoftInput(view, 0); + uiController.loopMainThreadUntilIdle(); + } + + @Override + public String getDescription() { + return "show soft input"; + } + }); + + onView(withId(R.id.enter_data_edit_text)).perform(ViewActions.closeSoftKeyboard()); + } + + public void testSetFailureHandler() { + final AtomicBoolean handled = new AtomicBoolean(false); + Espresso.setFailureHandler(new FailureHandler() { + @Override + public void handle(Throwable error, Matcher<View> viewMatcher) { + handled.set(true); + } + }); + onView(withText("does not exist")).perform(click()); + assertTrue(handled.get()); + } + + public void testRegisterResourceWithNullName() { + try { + Espresso.registerIdlingResources(new IdlingResource() { + @Override + public boolean isIdleNow() { + return true; + } + + @Override + public String getName() { + return null; + } + + @Override + public void registerIdleTransitionCallback(ResourceCallback callback) { + // ignore + } + }); + fail("Should have thrown NPE"); + } catch (NullPointerException expected) {} + } +} diff --git a/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/NoMatchingViewExceptionTest.java b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/NoMatchingViewExceptionTest.java new file mode 100644 index 0000000..16571e3 --- /dev/null +++ b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/NoMatchingViewExceptionTest.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; + +import android.test.AndroidTestCase; +import android.view.View; + +import org.hamcrest.Matcher; +import org.hamcrest.Matchers; +import org.hamcrest.StringDescription; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +/** Unit tests for {@link NoMatchingViewException}. */ +public class NoMatchingViewExceptionTest extends AndroidTestCase { + private Matcher<View> alwaysFailingMatcher; + + @Mock + private View testView; + + @Override + public void setUp() throws Exception { + super.setUp(); + MockitoAnnotations.initMocks(this); + alwaysFailingMatcher = Matchers.<View>nullValue(); + } + + public void testExceptionContainsMatcherDescription() { + StringBuilder matcherDescription = new StringBuilder(); + alwaysFailingMatcher.describeTo(new StringDescription(matcherDescription)); + assertThat(createException().getMessage(), containsString(matcherDescription.toString())); + } + + public void testExceptionContainsView() { + String exceptionMessage = createException().getMessage(); + + assertThat("missing root element" + exceptionMessage, exceptionMessage, + containsString("{id=0,")); + } + + private NoMatchingViewException createException() { + return new NoMatchingViewException.Builder() + .withViewMatcher(alwaysFailingMatcher) + .withRootView(testView) + .build(); + } +} diff --git a/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/UnitTests.java b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/UnitTests.java new file mode 100644 index 0000000..b3c3f98 --- /dev/null +++ b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/UnitTests.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso; + +import android.test.suitebuilder.TestSuiteBuilder; + +import junit.framework.Test; +import junit.framework.TestSuite; + +/** + * TestSuite containing "unit tests" for the UI Framework. + * + */ +public class UnitTests extends TestSuite { + public static Test suite() { + return new TestSuiteBuilder(UnitTests.class) + .includeAllPackagesUnderHere() + .build(); + } +} diff --git a/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/ViewInteractionTest.java b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/ViewInteractionTest.java new file mode 100755 index 0000000..295572c --- /dev/null +++ b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/ViewInteractionTest.java @@ -0,0 +1,208 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso; + +import static com.google.common.base.Throwables.propagate; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.nullValue; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.MockitoAnnotations.initMocks; + +import com.google.android.apps.common.testing.testrunner.ActivityLifecycleMonitor; +import com.google.android.apps.common.testing.testrunner.ActivityLifecycleMonitorRegistry; +import com.google.android.apps.common.testing.ui.espresso.matcher.RootMatchers; +import com.google.common.util.concurrent.MoreExecutors; + +import android.test.AndroidTestCase; +import android.view.View; + +import org.hamcrest.Matcher; +import org.hamcrest.Matchers; +import org.mockito.Mock; + +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicReference; + +/** Unit tests for {@link ViewInteraction}. */ +public class ViewInteractionTest extends AndroidTestCase { + @Mock + private ViewFinder mockViewFinder; + @Mock + private ViewAssertion mockAssertion; + @Mock + private ViewAction mockAction; + @Mock + private UiController mockUiController; + + + private FailureHandler failureHandler; + private Executor testExecutor = MoreExecutors.sameThreadExecutor(); + + private ActivityLifecycleMonitor realLifecycleMonitor; + private ViewInteraction testInteraction; + private View rootView; + private View targetView; + private Matcher<View> viewMatcher; + private Matcher<View> actionConstraint; + private AtomicReference<Matcher<Root>> rootMatcherRef; + + @Override + public void setUp() throws Exception { + super.setUp(); + initMocks(this); + realLifecycleMonitor = ActivityLifecycleMonitorRegistry.getInstance(); + rootView = new View(getContext()); + targetView = new View(getContext()); + viewMatcher = is(targetView); + actionConstraint = Matchers.<View>notNullValue(); + rootMatcherRef = new AtomicReference<Matcher<Root>>(RootMatchers.DEFAULT); + when(mockAction.getDescription()).thenReturn("A Mock!"); + failureHandler = new FailureHandler() { + @Override + public void handle(Throwable error, Matcher<View> viewMatcher) { + propagate(error); + } + }; + } + + @Override + public void tearDown() throws Exception { + ActivityLifecycleMonitorRegistry.registerInstance(realLifecycleMonitor); + super.tearDown(); + } + + public void testPerformViewViolatesConstraints() { + actionConstraint = not(viewMatcher); + when(mockViewFinder.getView()).thenReturn(targetView); + initInteraction(); + try { + testInteraction.perform(mockAction); + fail("should propagate constraint violation!"); + } catch (RuntimeException re) { + if (!PerformException.class.isAssignableFrom(re.getClass())) { + throw re; + } + } + } + + public void testPerformPropagatesException() { + RuntimeException exceptionToRaise = new RuntimeException(); + when(mockViewFinder.getView()).thenReturn(targetView); + doThrow(exceptionToRaise) + .when(mockAction) + .perform(mockUiController, targetView); + initInteraction(); + try { + testInteraction.perform(mockAction); + fail("Should propagate exception stored in view operation!"); + } catch (RuntimeException re) { + verify(mockAction).perform(mockUiController, targetView); + assertThat(exceptionToRaise, is(re)); + } + } + + public void testCheckPropagatesException() { + RuntimeException exceptionToRaise = new RuntimeException(); + when(mockViewFinder.getView()).thenReturn(targetView); + doThrow(exceptionToRaise) + .when(mockAssertion) + .check(targetView, null); + + initInteraction(); + try { + testInteraction.check(mockAssertion); + fail("Should propagate exception stored in view operation!"); + } catch (RuntimeException re) { + verify(mockAssertion).check(targetView, null); + assertThat(exceptionToRaise, is(re)); + } + } + + public void testPerformTwiceUpdatesPreviouslyMatched() { + View firstView = new View(getContext()); + View secondView = new View(getContext()); + when(mockViewFinder.getView()).thenReturn(firstView); + initInteraction(); + testInteraction.perform(mockAction); + verify(mockAction).perform(mockUiController, firstView); + + when(mockViewFinder.getView()).thenReturn(secondView); + testInteraction.perform(mockAction); + verify(mockAction).perform(mockUiController, secondView); + + testInteraction.check(mockAssertion); + verify(mockAssertion).check(secondView, null); + + } + + public void testPerformAndCheck() { + when(mockViewFinder.getView()).thenReturn(targetView); + initInteraction(); + testInteraction.perform(mockAction); + verify(mockAction).perform(mockUiController, targetView); + + testInteraction.check(mockAssertion); + verify(mockAssertion).check(targetView, null); + } + + public void testCheck() { + when(mockViewFinder.getView()).thenReturn(targetView); + initInteraction(); + testInteraction.check(mockAssertion); + verify(mockAssertion).check(targetView, null); + } + + public void testInRootUpdatesRef() { + initInteraction(); + Matcher<Root> testMatcher = nullValue(); + testInteraction.inRoot(testMatcher); + assertEquals(testMatcher, rootMatcherRef.get()); + } + + public void testInRoot_NullHandling() { + initInteraction(); + try { + testInteraction.inRoot(null); + fail("should throw"); + } catch (NullPointerException expected) { + } + } + + public void testCheck_ViewCannotBeFound() { + NoMatchingViewException noViewException = new NoMatchingViewException.Builder() + .withViewMatcher(viewMatcher) + .withRootView(rootView) + .build(); + + when(mockViewFinder.getView()).thenThrow(noViewException); + initInteraction(); + testInteraction.check(mockAssertion); + verify(mockAssertion).check(null, noViewException); + } + + private void initInteraction() { + when(mockAction.getConstraints()).thenReturn(actionConstraint); + + testInteraction = new ViewInteraction(mockUiController, mockViewFinder, testExecutor, + failureHandler, viewMatcher, rootMatcherRef); + + } +} diff --git a/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/action/AdapterDataIntegrationTest.java b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/action/AdapterDataIntegrationTest.java new file mode 100644 index 0000000..fe37fc5 --- /dev/null +++ b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/action/AdapterDataIntegrationTest.java @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.android.apps.common.testing.ui.espresso.Espresso.onData; +import static com.google.android.apps.common.testing.ui.espresso.Espresso.onView; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.click; +import static com.google.android.apps.common.testing.ui.espresso.assertion.ViewAssertions.matches; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.hasSibling; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withId; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.hasEntry; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; + +import com.google.android.apps.common.testing.ui.testapp.LongListActivity; +import com.google.android.apps.common.testing.ui.testapp.R; + +import android.test.ActivityInstrumentationTestCase2; +import android.test.suitebuilder.annotation.LargeTest; + +import java.util.Map; + +/** + * Integration tests for operating on data displayed in an adapter. + */ +@LargeTest +public class AdapterDataIntegrationTest extends ActivityInstrumentationTestCase2<LongListActivity> { + @SuppressWarnings("deprecation") + public AdapterDataIntegrationTest() { + // Supporting froyo. + super("com.google.android.apps.common.testing.ui.testapp", LongListActivity.class); + } + + @Override + public void setUp() throws Exception { + super.setUp(); + getActivity(); + } + + @SuppressWarnings("unchecked") + public void testClickAroundList() { + onData(allOf(is(instanceOf(Map.class)), hasEntry(is(LongListActivity.STR), is("item: 99")))) + .perform(click()); + onView(withId(R.id.selection_row_value)) + .check(matches(withText("99"))); + + onData(allOf(is(instanceOf(Map.class)), hasEntry(is(LongListActivity.STR), is("item: 1")))) + .perform(click()); + + onView(withId(R.id.selection_row_value)) + .check(matches(withText("1"))); + + onData(allOf(is(instanceOf(Map.class)))) + .atPosition(20) + .perform(click()); + + onView(withId(R.id.selection_row_value)) + .check(matches(withText("20"))); + + // lets operate on a specific child of a row... + onData(allOf(is(instanceOf(Map.class)), hasEntry(is(LongListActivity.STR), is("item: 50")))) + .onChildView(withId(R.id.item_size)) + .perform(click()) + .check(matches(withText(String.valueOf("item: 50".length())))); + + onView(withId(R.id.selection_row_value)) + .check(matches(withText("50"))); + } + + @SuppressWarnings("unchecked") + public void testSelectItemWithSibling() { + onView(allOf(withText("7"), hasSibling(withText("item: 0")))) + .perform(click()); + onView(withId(R.id.selection_row_value)) + .check(matches(withText("0"))); + } +} diff --git a/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/action/ClearTextActionIntegrationTest.java b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/action/ClearTextActionIntegrationTest.java new file mode 100644 index 0000000..cf2835f --- /dev/null +++ b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/action/ClearTextActionIntegrationTest.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.android.apps.common.testing.ui.espresso.Espresso.onView; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.clearText; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.typeText; +import static com.google.android.apps.common.testing.ui.espresso.assertion.ViewAssertions.matches; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withId; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.is; + +import com.google.android.apps.common.testing.ui.testapp.R; +import com.google.android.apps.common.testing.ui.testapp.SendActivity; + +import android.app.Activity; +import android.test.ActivityInstrumentationTestCase2; +import android.test.suitebuilder.annotation.LargeTest; + +/** + * {@link ClearTextAction} integration tests. + */ +@LargeTest +public class ClearTextActionIntegrationTest extends ActivityInstrumentationTestCase2<SendActivity> { + @SuppressWarnings("deprecation") + public ClearTextActionIntegrationTest() { + // Supporting froyo. + super("com.google.android.apps.common.testing.ui.testapp", SendActivity.class); + } + + @LargeTest + public void testClearTextActionPerform() { + Activity activity = getActivity(); + String text = activity.getText(R.string.send_data_to_message_edit_text).toString(); + onView(withId(is(R.id.send_data_to_message_edit_text))).check(matches(withText(is(text)))); + onView(withId(is(R.id.send_data_to_message_edit_text))).perform(clearText()); + onView(withId(is(R.id.send_data_to_message_edit_text))).check(matches(withText(is("")))); + } + + @LargeTest + public void testClearTextActionPerformWithTypeText() { + Activity activity = getActivity(); + String text = activity.getText(R.string.send_data_to_message_edit_text).toString(); + onView(withId(is(R.id.send_data_to_call_edit_text))).perform(typeText(text)); + onView(withId(is(R.id.send_data_to_call_edit_text))).check(matches(withText(is(text)))); + onView(withId(is(R.id.send_data_to_call_edit_text))).perform(clearText()); + onView(withId(is(R.id.send_data_to_call_edit_text))).check(matches(withText(is("")))); + } +} diff --git a/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/action/EditorActionIntegrationTest.java b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/action/EditorActionIntegrationTest.java new file mode 100644 index 0000000..0a99be9 --- /dev/null +++ b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/action/EditorActionIntegrationTest.java @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.android.apps.common.testing.ui.espresso.Espresso.onView; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.pressImeActionButton; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.scrollTo; +import static com.google.android.apps.common.testing.ui.espresso.assertion.ViewAssertions.matches; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.hasImeAction; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDisplayed; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withId; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.containsString; + +import com.google.android.apps.common.testing.ui.espresso.PerformException; +import com.google.android.apps.common.testing.ui.testapp.R; +import com.google.android.apps.common.testing.ui.testapp.SendActivity; + +import android.test.ActivityInstrumentationTestCase2; +import android.test.suitebuilder.annotation.LargeTest; +import android.view.inputmethod.EditorInfo; + +/** + * Tests for {@link EditorAction}. + */ +@LargeTest +public class EditorActionIntegrationTest extends ActivityInstrumentationTestCase2<SendActivity> { + @SuppressWarnings("deprecation") + public EditorActionIntegrationTest() { + // Supporting froyo. + super("com.google.android.apps.common.testing.ui.testapp", SendActivity.class); + } + + @Override + public void setUp() throws Exception { + super.setUp(); + getActivity(); + } + + @SuppressWarnings("unchecked") + public void testPressImeActionButtonOnSearchBox() { + String searchFor = "rainbows and unicorns"; + onView(withId(R.id.search_box)).perform(scrollTo(), ViewActions.typeText(searchFor)); + onView(withId(R.id.search_box)) + .check(matches(hasImeAction(EditorInfo.IME_ACTION_SEARCH))) + .perform(pressImeActionButton()); + onView(withId(R.id.search_result)).perform(scrollTo()); + onView(withId(R.id.search_result)) + .check(matches(allOf(isDisplayed(), withText(containsString(searchFor))))); + } + + public void testPressImeActionButtonOnNonEditorWidget() { + try { + onView(withId(R.id.send_button)).perform(pressImeActionButton()); + fail("Expected exception on previous call"); + } catch (PerformException expected) { + assertTrue(expected.getCause() instanceof IllegalStateException); + } + } + + public void testPressSearchOnDefaultEditText() { + onView(withId(R.id.enter_data_edit_text)).perform(pressImeActionButton()); + } +} diff --git a/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/action/EspressoKeyBuilderTest.java b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/action/EspressoKeyBuilderTest.java new file mode 100644 index 0000000..6df907d --- /dev/null +++ b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/action/EspressoKeyBuilderTest.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.action; + +import com.google.android.apps.common.testing.ui.espresso.action.EspressoKey.Builder; + +import android.os.Build; +import android.view.KeyEvent; + +import junit.framework.TestCase; + +/** + * Unit tests for {@link Builder}. + */ +public class EspressoKeyBuilderTest extends TestCase { + + static final int KEY_CODE = KeyEvent.KEYCODE_X; + + public void testBuildWithNoMetaState() { + EspressoKey key = new Builder().withKeyCode(KEY_CODE).build(); + assertEquals(KEY_CODE, key.getKeyCode()); + assertEquals(0, key.getMetaState()); + } + + public void testBuildWithShiftPressed() { + EspressoKey key = new Builder().withKeyCode(KEY_CODE).withShiftPressed(true).build(); + assertEquals(KEY_CODE, key.getKeyCode()); + assertEquals(KeyEvent.META_SHIFT_ON, key.getMetaState()); + } + + public void testBuildWithCtrlPressed() { + EspressoKey key = new Builder().withKeyCode(KEY_CODE).withCtrlPressed(true).build(); + assertEquals(KEY_CODE, key.getKeyCode()); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { + assertEquals(KeyEvent.META_CTRL_ON, key.getMetaState()); + } else { + assertEquals(0, key.getMetaState()); + } + } + + public void testBuildWithAltPressed() { + EspressoKey key = new Builder().withKeyCode(KEY_CODE).withAltPressed(true).build(); + assertEquals(KEY_CODE, key.getKeyCode()); + assertEquals(KeyEvent.META_ALT_ON, key.getMetaState()); + } + + public void testBuildWithAllMetaKeysPressed() { + EspressoKey key = new Builder().withKeyCode(KEY_CODE) + .withShiftPressed(true) + .withCtrlPressed(true) + .withAltPressed(true) + .build(); + + assertEquals(KEY_CODE, key.getKeyCode()); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { + assertEquals(KeyEvent.META_SHIFT_ON | KeyEvent.META_CTRL_ON | KeyEvent.META_ALT_ON, + key.getMetaState()); + } else { + assertEquals(KeyEvent.META_SHIFT_ON | KeyEvent.META_ALT_ON, key.getMetaState()); + } + } +}
\ No newline at end of file diff --git a/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/action/EventActionIntegrationTest.java b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/action/EventActionIntegrationTest.java new file mode 100644 index 0000000..fbf55ef --- /dev/null +++ b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/action/EventActionIntegrationTest.java @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.android.apps.common.testing.ui.espresso.Espresso.onView; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.click; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.doubleClick; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.longClick; +import static com.google.android.apps.common.testing.ui.espresso.assertion.ViewAssertions.matches; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isAssignableFrom; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDisplayed; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withId; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; + +import com.google.android.apps.common.testing.testrunner.annotations.SdkSuppress; +import com.google.android.apps.common.testing.ui.espresso.UiController; +import com.google.android.apps.common.testing.ui.espresso.ViewAction; +import com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers; +import com.google.android.apps.common.testing.ui.testapp.GestureActivity; +import com.google.android.apps.common.testing.ui.testapp.R; + +import android.test.ActivityInstrumentationTestCase2; +import android.test.suitebuilder.annotation.LargeTest; +import android.view.View; + +import org.hamcrest.Matcher; + +/** + * UI tests for ClickAction, LongClickAction and DoubleClickAction. + */ +@LargeTest +public class EventActionIntegrationTest extends ActivityInstrumentationTestCase2<GestureActivity> { + + @SuppressWarnings("deprecation") + public EventActionIntegrationTest() { + // Keep froyo happy. + super("com.google.android.apps.common.testing.ui.testapp", GestureActivity.class); + } + + @Override + public void setUp() throws Exception { + super.setUp(); + getActivity(); + } + + public void testClick() { + onView(withText(is(getActivity().getString(R.string.text_click)))) + .check(matches(not(isDisplayed()))); + onView(withId(is(R.id.gesture_area))).perform(click()); + onView(withId(is(R.id.text_click))).check(matches(isDisplayed())); + onView(withText(is(getActivity().getString(R.string.text_click)))) + .check(matches(isDisplayed())); + } + + public void testBadClick() { + onView(withText(is(getActivity().getString(R.string.text_click)))) + .check(matches(not(isDisplayed()))); + getActivity().setTouchDelay(700); + + onView(withId(is(R.id.gesture_area))).perform(click( + new ViewAction() { + @Override + public String getDescription() { + return "Handle tap->longclick."; + } + @Override + public Matcher<View> getConstraints() { + return isAssignableFrom(View.class); + } + @Override + public void perform(UiController uiController, View view) { + getActivity().setTouchDelay(0); + } + })); + + + onView(withId(is(R.id.text_click))).check(matches(isDisplayed())); + onView(withText(is(getActivity().getString(R.string.text_click)))) + .check(matches(isDisplayed())); + } + + @SdkSuppress(bugId = -1, versions = {7, 8, 13}) + public void testLongClick() { + onView(withText(is(getActivity().getString(R.string.text_long_click)))) + .check(matches(not(isDisplayed()))); + onView(withId(is(R.id.gesture_area))).perform(longClick()); + onView(withId(is(R.id.text_long_click))).check(matches(isDisplayed())); + onView(withText(is(getActivity().getString(R.string.text_long_click)))) + .check(matches(isDisplayed())); + } + + @SdkSuppress(bugId = -1, versions = {7, 8, 13}) + public void testDoubleClick() { + onView(withText(is(getActivity().getString(R.string.text_double_click)))) + .check(matches(not(ViewMatchers.isDisplayed()))); + onView(withId(is(R.id.gesture_area))).perform(doubleClick()); + onView(withId(is(R.id.text_double_click))).check(matches(isDisplayed())); + onView(withText(is("Double Click"))).check(matches(isDisplayed())); + onView(withText(is(getActivity().getString(R.string.text_double_click)))) + .check(matches(isDisplayed())); + } +} diff --git a/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/action/GeneralLocationTest.java b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/action/GeneralLocationTest.java new file mode 100644 index 0000000..944e660 --- /dev/null +++ b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/action/GeneralLocationTest.java @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.action; + +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.MockitoAnnotations.initMocks; + +import android.view.View; + +import junit.framework.TestCase; + +import org.mockito.Spy; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +/** + * Unit tests for {@link GeneralLocation}. + */ +public class GeneralLocationTest extends TestCase { + + private static final int VIEW_POSITION_X = 100; + private static final int VIEW_POSITION_Y = 50; + private static final int VIEW_WIDTH = 150; + private static final int VIEW_HEIGHT = 300; + + private static final int AXIS_X = 0; + private static final int AXIS_Y = 1; + + @Spy + private View mockView; + + @Override + public void setUp() throws Exception { + super.setUp(); + initMocks(this); + + doAnswer(new Answer<Void>() { + @Override + public Void answer(InvocationOnMock invocation) throws Throwable { + int[] array = (int[]) invocation.getArguments()[0]; + array[AXIS_X] = VIEW_POSITION_X; + array[AXIS_Y] = VIEW_POSITION_Y; + return null; + } + }).when(mockView).getLocationOnScreen(any(int[].class)); + + mockView.layout( + VIEW_POSITION_X, + VIEW_POSITION_Y, + VIEW_POSITION_X + VIEW_WIDTH, + VIEW_POSITION_Y + VIEW_HEIGHT); + } + + public void testLeftLocationsX() { + assertPositionEquals(VIEW_POSITION_X, GeneralLocation.TOP_LEFT, AXIS_X); + assertPositionEquals(VIEW_POSITION_X, GeneralLocation.CENTER_LEFT, AXIS_X); + assertPositionEquals(VIEW_POSITION_X, GeneralLocation.BOTTOM_LEFT, AXIS_X); + } + + public void testRightLocationsX() { + assertPositionEquals(VIEW_POSITION_X + VIEW_WIDTH, GeneralLocation.TOP_RIGHT, AXIS_X); + assertPositionEquals(VIEW_POSITION_X + VIEW_WIDTH, GeneralLocation.CENTER_RIGHT, AXIS_X); + assertPositionEquals(VIEW_POSITION_X + VIEW_WIDTH, GeneralLocation.BOTTOM_RIGHT, AXIS_X); + } + + public void testTopLocationsY() { + assertPositionEquals(VIEW_POSITION_Y, GeneralLocation.TOP_LEFT, AXIS_Y); + assertPositionEquals(VIEW_POSITION_Y, GeneralLocation.TOP_CENTER, AXIS_Y); + assertPositionEquals(VIEW_POSITION_Y, GeneralLocation.TOP_RIGHT, AXIS_Y); + } + + public void testBottomLocationsY() { + assertPositionEquals(VIEW_POSITION_Y + VIEW_HEIGHT, GeneralLocation.BOTTOM_LEFT, AXIS_Y); + assertPositionEquals(VIEW_POSITION_Y + VIEW_HEIGHT, GeneralLocation.BOTTOM_CENTER, AXIS_Y); + assertPositionEquals(VIEW_POSITION_Y + VIEW_HEIGHT, GeneralLocation.BOTTOM_RIGHT, AXIS_Y); + } + + public void testCenterLocationsX() { + assertPositionEquals(VIEW_POSITION_X + VIEW_WIDTH / 2, GeneralLocation.CENTER, AXIS_X); + assertPositionEquals(VIEW_POSITION_X + VIEW_WIDTH / 2, GeneralLocation.TOP_CENTER, AXIS_X); + assertPositionEquals(VIEW_POSITION_X + VIEW_WIDTH / 2, GeneralLocation.BOTTOM_CENTER, AXIS_X); + } + + public void testCenterLocationsY() { + assertPositionEquals(VIEW_POSITION_Y + VIEW_HEIGHT / 2, GeneralLocation.CENTER, AXIS_Y); + assertPositionEquals(VIEW_POSITION_Y + VIEW_HEIGHT / 2, GeneralLocation.CENTER_LEFT, AXIS_Y); + assertPositionEquals(VIEW_POSITION_Y + VIEW_HEIGHT / 2, GeneralLocation.CENTER_RIGHT, AXIS_Y); + } + + private void assertPositionEquals(int expected, GeneralLocation location, int axis) { + assertEquals(expected, location.calculateCoordinates(mockView)[axis], 0.1f); + } + +} diff --git a/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/action/KeyEventActionIntegrationTest.java b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/action/KeyEventActionIntegrationTest.java new file mode 100644 index 0000000..c75c3fb --- /dev/null +++ b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/action/KeyEventActionIntegrationTest.java @@ -0,0 +1,151 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.android.apps.common.testing.ui.espresso.Espresso.onData; +import static com.google.android.apps.common.testing.ui.espresso.Espresso.onView; +import static com.google.android.apps.common.testing.ui.espresso.Espresso.pressBack; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.click; +import static com.google.android.apps.common.testing.ui.espresso.assertion.ViewAssertions.matches; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isAssignableFrom; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDisplayed; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isRoot; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withId; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withParent; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasValue; +import static org.hamcrest.Matchers.instanceOf; + +import com.google.android.apps.common.testing.testrunner.annotations.SdkSuppress; +import com.google.android.apps.common.testing.ui.espresso.NoActivityResumedException; +import com.google.android.apps.common.testing.ui.testapp.MainActivity; +import com.google.android.apps.common.testing.ui.testapp.R; + +import android.content.Intent; +import android.test.ActivityInstrumentationTestCase2; +import android.test.FlakyTest; +import android.test.suitebuilder.annotation.LargeTest; +import android.view.KeyEvent; +import android.widget.TextView; + +import java.util.Map; + + +/** + * Integration tests for {@link KeyEventAction}. + */ +@LargeTest +public class KeyEventActionIntegrationTest extends ActivityInstrumentationTestCase2<MainActivity> { + @SuppressWarnings("deprecation") + public KeyEventActionIntegrationTest() { + // Supporting froyo. + super("com.google.android.apps.common.testing.ui.testapp", MainActivity.class); + } + + @Override + public void setUp() throws Exception { + super.setUp(); + } + + public void testClickBackOnRootAction() { + getActivity(); + try { + pressBack(); + fail("Should have thrown NoActivityResumedException"); + } catch (NoActivityResumedException expected) { + } + } + + @SuppressWarnings("unchecked") + public void testClickBackOnNonRootActivityLatte() { + getActivity(); + onData(allOf(instanceOf(Map.class), hasValue("SendActivity"))).perform(click()); + pressBack(); + + // Make sure we are back. + onData(allOf(instanceOf(Map.class), hasValue("SendActivity"))).check(matches(isDisplayed())); + } + + @SuppressWarnings("unchecked") + public void testClickBackOnNonRootActionNoLatte() { + getActivity(); + onData(allOf(instanceOf(Map.class), hasValue("SendActivity"))).perform(click()); + onView(isRoot()).perform(ViewActions.pressBack()); + + // Make sure we are back. + onData(allOf(instanceOf(Map.class), hasValue("SendActivity"))).check(matches(isDisplayed())); + } + + @SuppressWarnings("unchecked") + @SdkSuppress(versions = {7, 8, 10}, bugId = -1) // uses native fragments. + @FlakyTest + public void testClickOnBackFromFragment() { + Intent fragmentStack = new Intent().setClassName(getInstrumentation().getTargetContext(), + "com.google.android.apps.common.testing.ui.testapp.FragmentStack"); + fragmentStack.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + getInstrumentation().startActivitySync(fragmentStack); + onView(allOf(withParent(withId(R.id.simple_fragment)), isAssignableFrom(TextView.class))) + .check(matches(withText(containsString("#1")))); + try { + pressBack(); + fail("Should have thrown NoActivityResumedException"); + } catch (NoActivityResumedException expected) { + } + getInstrumentation().startActivitySync(fragmentStack); + + onView(withId(R.id.new_fragment)).perform(click()).perform(click()).perform(click()); + + onView(allOf(withParent(withId(R.id.simple_fragment)), isAssignableFrom(TextView.class))) + .check(matches(withText(containsString("#4")))); + + pressBack(); + + onView(allOf(withParent(withId(R.id.simple_fragment)), isAssignableFrom(TextView.class))) + .check(matches(withText(containsString("#3")))); + + pressBack(); + + onView(allOf(withParent(withId(R.id.simple_fragment)), isAssignableFrom(TextView.class))) + .check(matches(withText(containsString("#2")))); + + pressBack(); + + onView(allOf(withParent(withId(R.id.simple_fragment)), isAssignableFrom(TextView.class))) + .check(matches(withText(containsString("#1")))); + + try { + pressBack(); + fail("Should have thrown NoActivityResumedException"); + } catch (NoActivityResumedException expected) { + } + } + + @SuppressWarnings("unchecked") + public void testPressKeyWithKeyCode() { + getActivity(); + onData(allOf(instanceOf(Map.class), hasValue("SendActivity"))).perform(click()); + onView(withId(R.id.enter_data_edit_text)).perform(click()); + onView(withId(R.id.enter_data_edit_text)).perform(ViewActions.pressKey(KeyEvent.KEYCODE_X)); + onView(withId(R.id.enter_data_edit_text)).perform(ViewActions.pressKey(KeyEvent.KEYCODE_Y)); + onView(withId(R.id.enter_data_edit_text)).perform(ViewActions.pressKey(KeyEvent.KEYCODE_Z)); + onView(withId(R.id.enter_data_edit_text)).perform(ViewActions.pressKey(KeyEvent.KEYCODE_ENTER)); + onView(allOf(withId(R.id.enter_data_response_text), withText("xyz"))) + .check(matches(isDisplayed())); + } +} diff --git a/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/action/ScrollToActionIntegrationTest.java b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/action/ScrollToActionIntegrationTest.java new file mode 100644 index 0000000..60ca48b --- /dev/null +++ b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/action/ScrollToActionIntegrationTest.java @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.android.apps.common.testing.ui.espresso.Espresso.onView; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.scrollTo; +import static com.google.android.apps.common.testing.ui.espresso.assertion.ViewAssertions.matches; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDisplayed; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withId; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; + +import com.google.android.apps.common.testing.ui.testapp.R; +import com.google.android.apps.common.testing.ui.testapp.ScrollActivity; + +import android.test.ActivityInstrumentationTestCase2; +import android.test.suitebuilder.annotation.LargeTest; + +/** + * Tests for ScrollToAction. + */ +@LargeTest +public class ScrollToActionIntegrationTest extends ActivityInstrumentationTestCase2<ScrollActivity> +{ + @SuppressWarnings("deprecation") + public ScrollToActionIntegrationTest() { + // Keep froyo happy. + super("com.google.android.apps.common.testing.ui.testapp", ScrollActivity.class); + } + + @Override + protected void setUp() throws Exception { + super.setUp(); + getActivity(); + } + + public void testScrollDown() { + onView(withId(is(R.id.bottom_left))) + .check(matches(not(isDisplayed()))) + .perform(scrollTo()) + .check(matches(isDisplayed())) + .perform(scrollTo()); // Should be a noop. + } + + public void testScrollVerticalAndHorizontal() { + onView(withId(is(R.id.bottom_right))) + .check(matches(not(isDisplayed()))) + .perform(scrollTo()) + .check(matches(isDisplayed())); + onView(withId(is(R.id.top_left))) + .check(matches(not(isDisplayed()))) + .perform(scrollTo()) + .check(matches(isDisplayed())); + } + + public void testScrollWithinScroll() { + onView(withId(is(R.id.double_scroll))) + .check(matches(not(isDisplayed()))) + .perform(scrollTo()) + .check(matches(isDisplayed())); + } +} diff --git a/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/action/SwipeActionIntegrationTest.java b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/action/SwipeActionIntegrationTest.java new file mode 100644 index 0000000..90257bc --- /dev/null +++ b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/action/SwipeActionIntegrationTest.java @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.android.apps.common.testing.ui.espresso.Espresso.onView; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.swipeLeft; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.swipeRight; +import static com.google.android.apps.common.testing.ui.espresso.assertion.ViewAssertions.matches; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.hasDescendant; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDisplayed; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withId; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.allOf; + +import com.google.android.apps.common.testing.ui.testapp.R; +import com.google.android.apps.common.testing.ui.testapp.SwipeActivity; + +import android.test.ActivityInstrumentationTestCase2; +import android.test.suitebuilder.annotation.LargeTest; + +/** + * Integration tests for swiping actions. + */ +@LargeTest +public class SwipeActionIntegrationTest extends ActivityInstrumentationTestCase2<SwipeActivity> { + + @SuppressWarnings("deprecation") + public SwipeActionIntegrationTest() { + // Keep froyo happy. + super("com.google.android.apps.common.testing.ui.testapp", SwipeActivity.class); + } + + @Override + public void setUp() throws Exception { + super.setUp(); + getActivity(); + } + + /** Tests that a small view can be swiped in both directions. */ + public void testSwipeOverSmallView() { + onView(withId(R.id.small_pager)) + .check(matches(hasDescendant(withText("Position #0")))) + .perform(swipeLeft()) + .check(matches(hasDescendant(withText("Position #1")))) + .perform(swipeLeft()) + .check(matches(hasDescendant(withText("Position #2")))) + .perform(swipeRight()) + .check(matches(hasDescendant(withText("Position #1")))) + .perform(swipeRight()) + .check(matches(hasDescendant(withText("Position #0")))); + } + + /** Tests that trying to swipe beyond the start of a view pager has no effect. */ + public void testSwipingRightHasNoEffectWhenAtStart() { + onView(withId(R.id.small_pager)) + .check(matches(hasDescendant(withText("Position #0")))) + .perform(swipeRight()) + .check(matches(hasDescendant(withText("Position #0")))) + .perform(swipeRight()) + .check(matches(hasDescendant(withText("Position #0")))); + } + + /** Tests that trying to swipe beyond the end of a view pager has no effect. */ + public void testSwipingLeftHasNoEffectWhenAtEnd() { + onView(withId(R.id.small_pager)) + .perform(swipeLeft()) + .perform(swipeLeft()) + .check(matches(hasDescendant(withText("Position #2")))) + .perform(swipeLeft()) + .check(matches(hasDescendant(withText("Position #2")))) + .perform(swipeLeft()) + .check(matches(hasDescendant(withText("Position #2")))); + } + + /** Tests that swiping across a partially overlapped view works correctly. */ + public void testSwipeOverPartiallyOverlappedView() { + onView(withId(R.id.overlapped_pager)) + .check(matches(hasDescendant(withText("Position #0")))) + .perform(swipeLeft()) + .check(matches(hasDescendant(withText("Position #1")))) + .perform(swipeRight()) + .check(matches(hasDescendant(withText("Position #0")))); + } + + /** Tests that trying to swipe a view that doesn't respond to swipes has no effect. */ + @SuppressWarnings("unchecked") + public void testSwipeOverUnswipableView() { + onView(withId(R.id.text_simple)) + .check(matches(allOf(isDisplayed(), withText(R.string.text_simple)))) + .perform(swipeLeft()) + .check(matches(allOf(isDisplayed(), withText(R.string.text_simple)))) + .perform(swipeRight()) + .check(matches(allOf(isDisplayed(), withText(R.string.text_simple)))); + } + +} diff --git a/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/action/TypeTextActionIntegrationTest.java b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/action/TypeTextActionIntegrationTest.java new file mode 100644 index 0000000..b1130f1 --- /dev/null +++ b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/action/TypeTextActionIntegrationTest.java @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.android.apps.common.testing.ui.espresso.Espresso.onView; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.pressImeActionButton; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.scrollTo; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.typeText; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.typeTextIntoFocusedView; +import static com.google.android.apps.common.testing.ui.espresso.assertion.ViewAssertions.matches; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDisplayed; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withId; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withParent; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; + +import com.google.android.apps.common.testing.ui.espresso.PerformException; +import com.google.android.apps.common.testing.ui.testapp.R; +import com.google.android.apps.common.testing.ui.testapp.SendActivity; + +import android.test.ActivityInstrumentationTestCase2; +import android.test.suitebuilder.annotation.LargeTest; + +/** + * {@link TypeTextAction} integration tests. + */ +@LargeTest +public class TypeTextActionIntegrationTest extends ActivityInstrumentationTestCase2<SendActivity> { + @SuppressWarnings("deprecation") + public TypeTextActionIntegrationTest() { + // Supporting froyo. + super("com.google.android.apps.common.testing.ui.testapp", SendActivity.class); + } + + @Override + public void setUp() throws Exception { + super.setUp(); + getActivity(); + } + + public void testTypeTextActionPerform() { + onView(withId(is(R.id.send_data_to_call_edit_text))).perform(typeText("Hello!")); + } + + @SuppressWarnings("unchecked") + public void testTypeTextActionPerformWithEnter() { + onView(withId(R.id.enter_data_edit_text)).perform(typeText("Hello World!\n")); + onView(allOf(withId(R.id.enter_data_response_text), withText("Hello World!"))) + .check(matches(isDisplayed())); + } + + public void testTypeTextInFocusedView() { + onView(withId(is(R.id.send_data_to_call_edit_text))).perform(typeText( + "Hello World How Are You Today? I have alot of text to type.")); + onView(withId(is(R.id.send_data_to_call_edit_text))).perform(typeTextIntoFocusedView( + "Jolly good!")); + onView(withId(is(R.id.send_data_to_call_edit_text))).check(matches(withText( + "Hello World How Are You Today? I have alot of text to type.Jolly good!"))); + } + + public void testTypeTextInFocusedView_constraintBreakage() { + onView(withId(is(R.id.send_data_to_call_edit_text))).perform(typeText( + "Hello World How Are You Today? I have alot of text to type.")); + try { + onView(withId(is(R.id.edit_text_message))) + .perform(scrollTo(), typeTextIntoFocusedView("Jolly good!")); + fail("Should not have been able to type into focused view."); + } catch (PerformException expected) { + } + } + + @SuppressWarnings("unchecked") + public void testTypeTextInDelegatedEditText() { + String toType = "honeybadger doesn't care"; + onView(allOf(withParent(withId(R.id.delegating_edit_text)), withId(R.id.delegate_edit_text))) + .perform(scrollTo(), typeText(toType), pressImeActionButton()); + onView(withId(R.id.edit_text_message)) + .perform(scrollTo()) + .check(matches(withText(containsString(toType)))); + } +} diff --git a/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/action/TypeTextActionTest.java b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/action/TypeTextActionTest.java new file mode 100644 index 0000000..acddc06 --- /dev/null +++ b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/action/TypeTextActionTest.java @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.action; + +import static org.mockito.Matchers.isA; +import static org.mockito.Mockito.when; +import static org.mockito.MockitoAnnotations.initMocks; + +import com.google.android.apps.common.testing.ui.espresso.InjectEventSecurityException; +import com.google.android.apps.common.testing.ui.espresso.PerformException; +import com.google.android.apps.common.testing.ui.espresso.UiController; + +import android.view.MotionEvent; +import android.view.View; + +import junit.framework.TestCase; + +import org.mockito.Mock; + +/** + * Unit tests for {@link TypeTextAction}. + */ +public class TypeTextActionTest extends TestCase { + @Mock + private UiController mockUiController; + + @Mock + private View mockView; + + private TypeTextAction typeTextAction; + + @Override + public void setUp() throws Exception { + super.setUp(); + initMocks(this); + } + + public void testTypeTextActionPerform() throws InjectEventSecurityException { + String stringToBeTyped = "Hello!"; + typeTextAction = new TypeTextAction(stringToBeTyped); + when(mockUiController.injectMotionEvent(isA(MotionEvent.class))).thenReturn(true); + when(mockUiController.injectString(stringToBeTyped)).thenReturn(true); + typeTextAction.perform(mockUiController, mockView); + } + + public void testTypeTextActionPerformFailed() throws InjectEventSecurityException { + String stringToBeTyped = "Hello!"; + typeTextAction = new TypeTextAction(stringToBeTyped); + when(mockUiController.injectMotionEvent(isA(MotionEvent.class))).thenReturn(true); + when(mockUiController.injectString(stringToBeTyped)).thenReturn(false); + + try { + typeTextAction.perform(mockUiController, mockView); + fail("Should have thrown PerformException"); + } catch (PerformException e) { + if (e.getCause() instanceof InjectEventSecurityException) { + fail("Exception cause should NOT be of type InjectEventSecurityException"); + } + } + } + + public void testTypeTextActionPerformInjectEventSecurityException() + throws InjectEventSecurityException { + String stringToBeTyped = "Hello!"; + typeTextAction = new TypeTextAction(stringToBeTyped); + when(mockUiController.injectMotionEvent(isA(MotionEvent.class))).thenReturn(true); + when(mockUiController.injectString(stringToBeTyped)) + .thenThrow(new InjectEventSecurityException("")); + + try { + typeTextAction.perform(mockUiController, mockView); + fail("Should have thrown PerformException"); + } catch (PerformException e) { + if (!(e.getCause() instanceof InjectEventSecurityException)) { + fail("Exception cause should be of type InjectEventSecurityException"); + } + } + } +} diff --git a/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/action/WindowOrderingIntegrationTest.java b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/action/WindowOrderingIntegrationTest.java new file mode 100644 index 0000000..d50e409 --- /dev/null +++ b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/action/WindowOrderingIntegrationTest.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.android.apps.common.testing.ui.espresso.Espresso.onView; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.click; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.scrollTo; +import static com.google.android.apps.common.testing.ui.espresso.assertion.ViewAssertions.doesNotExist; +import static com.google.android.apps.common.testing.ui.espresso.assertion.ViewAssertions.matches; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDisplayed; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withId; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withText; + +import com.google.android.apps.common.testing.ui.testapp.R; +import com.google.android.apps.common.testing.ui.testapp.SendActivity; + +import android.os.Build; +import android.test.ActivityInstrumentationTestCase2; +import android.test.suitebuilder.annotation.LargeTest; + +/** + * Ensures view root ordering works properly. + */ +@LargeTest +public class WindowOrderingIntegrationTest extends ActivityInstrumentationTestCase2<SendActivity> { + @SuppressWarnings("deprecation") + public WindowOrderingIntegrationTest() { + // Supporting froyo. + super("com.google.android.apps.common.testing.ui.testapp", SendActivity.class); + } + + @Override + public void setUp() throws Exception { + super.setUp(); + getActivity(); + } + + public void testPopupMenu() { + if (Build.VERSION.SDK_INT < 11) { + // popup menus are post honeycomb. + return; + } + onView(withText(R.string.item_1_text)) + .check(doesNotExist()); + onView(withId(R.id.make_popup_menu_button)) + .perform(scrollTo(), click()); + onView(withText(R.string.item_1_text)) + .check(matches(isDisplayed())) + .perform(click()); + onView(withText(R.string.item_1_text)) + .check(doesNotExist()); + } + + public void testPopupWindow() { + getActivity(); + onView(withId(R.id.popup_title)) + .check(doesNotExist()); + onView(withId(R.id.make_popup_view_button)) + .perform(scrollTo(), click()); + onView(withId(R.id.popup_title)) + .check(matches(withText(R.string.popup_title))) + .perform(click()); + onView(withId(R.id.popup_title)) + .check(doesNotExist()); + } + + public void testDialog() { + onView(withText(R.string.dialog_title)) + .check(doesNotExist()); + onView(withId(R.id.make_alert_dialog)) + .perform(scrollTo(), click()); + onView(withText(R.string.dialog_title)) + .check(matches(isDisplayed())); + + onView(withText("Fine")) + .perform(click()); + + onView(withText(R.string.dialog_title)) + .check(doesNotExist()); + } +} diff --git a/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/assertion/ViewAssertionsTest.java b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/assertion/ViewAssertionsTest.java new file mode 100644 index 0000000..b49a822 --- /dev/null +++ b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/assertion/ViewAssertionsTest.java @@ -0,0 +1,170 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.assertion; + +import static com.google.android.apps.common.testing.ui.espresso.assertion.ViewAssertions.matches; +import static com.google.android.apps.common.testing.ui.espresso.assertion.ViewAssertions.selectedDescendantsMatch; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.hasContentDescription; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isAssignableFrom; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.nullValue; + +import com.google.android.apps.common.testing.ui.espresso.NoMatchingViewException; + +import android.test.InstrumentationTestCase; +import android.view.View; +import android.view.ViewGroup; +import android.widget.RelativeLayout; +import android.widget.ScrollView; +import android.widget.TextView; + +import junit.framework.AssertionFailedError; + +import org.hamcrest.Matcher; + +/** + * Unit tests for {@link ViewAssertions}. + */ +public class ViewAssertionsTest extends InstrumentationTestCase { + + private View presentView; + private View absentView; + private NoMatchingViewException absentException; + private NoMatchingViewException presentException; + private Matcher<View> alwaysAccepts; + private Matcher<View> alwaysFails; + private Matcher<View> nullViewMatcher; + + @Override + public void setUp() throws Exception { + super.setUp(); + presentView = new View(getInstrumentation().getTargetContext()); + absentView = null; + absentException = null; + alwaysAccepts = is(presentView); + alwaysFails = not(is(presentView)); + nullViewMatcher = nullValue(); + + presentException = new NoMatchingViewException.Builder() + .withViewMatcher(alwaysFails) + .withRootView(new View(getInstrumentation().getTargetContext())) + .build(); + } + + public void testViewPresent_MatcherFail() { + try { + matches(alwaysFails).check(presentView, absentException); + } catch (AssertionFailedError expected) { + return; + } + // cannot place inside try block, would be caught. + fail("Should not accept."); + } + + public void testViewPresent_MatcherPass() { + try { + matches(alwaysAccepts).check(presentView, absentException); + } catch (AssertionError error) { + throw new RuntimeException("Should not die!!!", error); + } + } + + public void testViewAbsent_Unexpectedly() { + try { + matches(alwaysAccepts).check(absentView, presentException); + } catch (NoMatchingViewException expected) { + return; + } + + fail("should not accept, view not present."); + } + + public void testViewAbsent_AndThatsWhatIWant() { + try { + matches(nullViewMatcher).check(absentView, presentException); + } catch (NoMatchingViewException expected) { + return; + } + + fail("should not accept, view not present."); + } + + public void testSelectedDescendantsMatch_ThereAreNone() { + View grany = setUpViewHierarchy(); + + try { + selectedDescendantsMatch(withText("welfjkw"), hasContentDescription()) + .check(grany, absentException); + } catch (AssertionError error) { + throw new RuntimeException("Should not die!!!", error); + } + } + + public void testSelectedDescendantsMatch_SelectedDescendantsMatch() { + View grany = setUpViewHierarchy(); + + try { + selectedDescendantsMatch(withText("has content description"), hasContentDescription()) + .check(grany, absentException); + } catch (AssertionError error) { + throw new RuntimeException("Should not die!!!", error); + } + } + + public void testSelectedDescendantsMatch_SelectedDescendantsDoNotMatch() { + View grany = setUpViewHierarchy(); + + try { + selectedDescendantsMatch(withText("no content description"), hasContentDescription()) + .check(grany, absentException); + } catch (AssertionFailedError expected) { + return; + } + + fail("should fail because descendants do not match."); + } + + public void testSelectedDescendantsMatch_SelectedDescendantsMatchAndDoNotMatch() { + View grany = setUpViewHierarchy(); + + try { + selectedDescendantsMatch(isAssignableFrom(TextView.class), hasContentDescription()) + .check(grany, absentException); + } catch (AssertionFailedError expected) { + return; + } + + fail("should fail because not all descendants match."); + } + + private View setUpViewHierarchy() { + TextView v1 = new TextView(getInstrumentation().getTargetContext()); + v1.setText("no content description"); + TextView v2 = new TextView(getInstrumentation().getTargetContext()); + v2.setText("has content description"); + v2.setContentDescription("content description"); + ViewGroup parent = new RelativeLayout(getInstrumentation().getTargetContext()); + View grany = new ScrollView(getInstrumentation().getTargetContext()); + ((ViewGroup) grany).addView(parent); + parent.addView(v1); + parent.addView(v2); + + return grany; + } +} diff --git a/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/base/AsyncTaskPoolMonitorTest.java b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/base/AsyncTaskPoolMonitorTest.java new file mode 100644 index 0000000..f400cf0 --- /dev/null +++ b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/base/AsyncTaskPoolMonitorTest.java @@ -0,0 +1,179 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.base; + +import junit.framework.TestCase; + +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.FutureTask; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Unit test for {@link AsyncTaskPoolMonitor} + */ +public class AsyncTaskPoolMonitorTest extends TestCase { + + private final ThreadPoolExecutor testThreadPool = new ThreadPoolExecutor( + 4, 4, 1, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>()); + + private AsyncTaskPoolMonitor monitor = new AsyncTaskPoolMonitor(testThreadPool); + + @Override + public void tearDown() throws Exception { + testThreadPool.shutdownNow(); + super.tearDown(); + } + + public void testIsIdle_onEmptyPool() throws Exception { + assertTrue(monitor.isIdleNow()); + final AtomicBoolean isIdle = new AtomicBoolean(false); + // since we're already idle, this should be ran immedately on our thread. + monitor.notifyWhenIdle(new Runnable() { + @Override + public void run() { + isIdle.set(true); + } + }); + assertTrue(isIdle.get()); + } + + public void testIsIdle_withRunningTask() throws Exception { + final CountDownLatch runLatch = new CountDownLatch(1); + testThreadPool.submit(new Runnable() { + @Override + public void run() { + runLatch.countDown(); + try { + Thread.sleep(50000); + } catch (InterruptedException ie) { + throw new RuntimeException(ie); + } + } + }); + assertTrue(runLatch.await(1, TimeUnit.SECONDS)); + assertFalse(monitor.isIdleNow()); + + final AtomicBoolean isIdle = new AtomicBoolean(false); + monitor.notifyWhenIdle(new Runnable() { + @Override + public void run() { + isIdle.set(true); + } + }); + // runnable shouldn't be run ever.. + assertFalse(isIdle.get()); + } + + + public void testIdleNotificationAndRestart() throws Exception { + + FutureTask<Thread> workerThreadFetchTask = new FutureTask<Thread>(new Callable<Thread>() { + @Override + public Thread call() { + return Thread.currentThread(); + } + }); + testThreadPool.submit(workerThreadFetchTask); + + Thread workerThread = workerThreadFetchTask.get(); + + final CountDownLatch runLatch = new CountDownLatch(1); + final CountDownLatch exitLatch = new CountDownLatch(1); + + testThreadPool.submit(new Runnable() { + @Override + public void run() { + runLatch.countDown(); + try { + exitLatch.await(); + } catch (InterruptedException ie) { + throw new RuntimeException(ie); + } + } + }); + + assertTrue(runLatch.await(1, TimeUnit.SECONDS)); + final CountDownLatch notificationLatch = new CountDownLatch(1); + monitor.notifyWhenIdle(new Runnable() { + @Override + public void run() { + notificationLatch.countDown(); + } + }); + // give some time for the idle detection threads to spin up. + Thread.sleep(2000); + // interrupt one of them + workerThread.interrupt(); + Thread.sleep(1000); + // unblock the dummy work item. + exitLatch.countDown(); + assertTrue(notificationLatch.await(1, TimeUnit.SECONDS)); + assertTrue(monitor.isIdleNow()); + } + + public void testIdleNotification_extraWork() throws Exception { + final CountDownLatch firstRunLatch = new CountDownLatch(1); + final CountDownLatch firstExitLatch = new CountDownLatch(1); + + testThreadPool.submit(new Runnable() { + @Override + public void run() { + firstRunLatch.countDown(); + try { + firstExitLatch.await(); + } catch (InterruptedException ie) { + throw new RuntimeException(ie); + } + } + }); + + assertTrue(firstRunLatch.await(1, TimeUnit.SECONDS)); + + final CountDownLatch notificationLatch = new CountDownLatch(1); + monitor.notifyWhenIdle(new Runnable() { + @Override + public void run() { + notificationLatch.countDown(); + } + }); + + final CountDownLatch secondRunLatch = new CountDownLatch(1); + final CountDownLatch secondExitLatch = new CountDownLatch(1); + testThreadPool.submit(new Runnable() { + @Override + public void run() { + secondRunLatch.countDown(); + try { + secondExitLatch.await(); + } catch (InterruptedException ie) { + throw new RuntimeException(ie); + } + } + }); + + assertFalse(notificationLatch.await(10, TimeUnit.MILLISECONDS)); + firstExitLatch.countDown(); + assertFalse(notificationLatch.await(500, TimeUnit.MILLISECONDS)); + secondExitLatch.countDown(); + assertTrue(notificationLatch.await(1, TimeUnit.SECONDS)); + assertTrue(monitor.isIdleNow()); + } +} diff --git a/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/base/DefaultFailureHandlerTest.java b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/base/DefaultFailureHandlerTest.java new file mode 100644 index 0000000..b84584c --- /dev/null +++ b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/base/DefaultFailureHandlerTest.java @@ -0,0 +1,116 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.base; + +import static com.google.android.apps.common.testing.ui.espresso.Espresso.onView; +import static com.google.android.apps.common.testing.ui.espresso.assertion.ViewAssertions.matches; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDisplayed; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isRoot; +import static com.google.common.base.Throwables.getStackTraceAsString; +import static org.hamcrest.Matchers.not; + +import com.google.android.apps.common.testing.ui.espresso.AmbiguousViewMatcherException; +import com.google.android.apps.common.testing.ui.espresso.NoMatchingViewException; +import com.google.android.apps.common.testing.ui.espresso.ViewAssertion; +import com.google.android.apps.common.testing.ui.testapp.MainActivity; + +import android.test.ActivityInstrumentationTestCase2; +import android.view.View; + +import junit.framework.AssertionFailedError; + +import org.hamcrest.BaseMatcher; +import org.hamcrest.Description; +import org.hamcrest.Matcher; + +/** + * Tests Espresso's default failure handling. + */ +public class DefaultFailureHandlerTest extends ActivityInstrumentationTestCase2<MainActivity> { + + @SuppressWarnings("deprecation") + public DefaultFailureHandlerTest() { + // Supporting froyo. + super("com.google.android.apps.common.testing.ui.testapp", MainActivity.class); + } + + @Override + public void setUp() throws Exception { + super.setUp(); + getActivity(); + } + + public void testMismatchInCheck() { + try { + onView(isRoot()).check(matches(not(isDisplayed()))); + fail("Previous call expected to fail"); + } catch (AssertionFailedError e) { + assertFailureStackContainsThisClass(e); + } + } + + public void testCustomAssertionError() { + try { + onView(isRoot()).check(new ViewAssertion() { + @Override + public void check(View view, NoMatchingViewException noViewFoundException) { + assertFalse(true); + } + }); + fail("Previous call expected to fail"); + } catch (AssertionFailedError e) { + assertFailureStackContainsThisClass(e); + } + } + + public void testNoMatchingViewException() { + try { + onView(withMatchesThatReturns(false)).check(matches(not(isDisplayed()))); + fail("Previous call expected to fail"); + } catch (NoMatchingViewException e) { + assertFailureStackContainsThisClass(e); + } + } + + public void testAmbiguousViewMatcherException() { + try { + onView(withMatchesThatReturns(true)).check(matches(isDisplayed())); + } catch (RuntimeException e) { + assertTrue(e instanceof AmbiguousViewMatcherException); + assertFailureStackContainsThisClass(e); + } + } + + private void assertFailureStackContainsThisClass(Throwable e) { + assertTrue(getStackTraceAsString(e).contains(getClass().getSimpleName().toString())); + } + + private static Matcher<View> withMatchesThatReturns(final boolean returnValue) { + return new BaseMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("matches=" + returnValue); + } + + @Override + public boolean matches(Object item) { + return returnValue; + } + }; + } + +} diff --git a/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/base/EventInjectorTest.java b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/base/EventInjectorTest.java new file mode 100644 index 0000000..bbc367a --- /dev/null +++ b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/base/EventInjectorTest.java @@ -0,0 +1,207 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.base; + +import com.google.android.apps.common.testing.testrunner.ActivityLifecycleCallback; +import com.google.android.apps.common.testing.testrunner.ActivityLifecycleMonitorRegistry; +import com.google.android.apps.common.testing.testrunner.Stage; +import com.google.android.apps.common.testing.ui.espresso.InjectEventSecurityException; +import com.google.android.apps.common.testing.ui.testapp.R; +import com.google.android.apps.common.testing.ui.testapp.SendActivity; + +import android.app.Activity; +import android.os.Build; +import android.os.SystemClock; +import android.test.ActivityInstrumentationTestCase2; +import android.test.suitebuilder.annotation.LargeTest; +import android.util.Log; +import android.view.KeyCharacterMap; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.View; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Tests for {@link EventInjector}. + */ +public class EventInjectorTest extends ActivityInstrumentationTestCase2<SendActivity> { + private static final String TAG = EventInjectorTest.class.getSimpleName(); + private Activity sendActivity; + private EventInjector injector; + final AtomicBoolean injectEventWorked = new AtomicBoolean(false); + final AtomicBoolean injectEventThrewSecurityException = new AtomicBoolean(false); + final CountDownLatch latch = new CountDownLatch(1); + + @SuppressWarnings("deprecation") + public EventInjectorTest() { + // Supporting froyo. + super("com.google.android.apps.common.testing.ui.testapp", SendActivity.class); + } + + @Override + public void setUp() throws Exception { + super.setUp(); + if (Build.VERSION.SDK_INT > 15) { + InputManagerEventInjectionStrategy strat = new InputManagerEventInjectionStrategy(); + strat.initialize(); + injector = new EventInjector(strat); + } else { + WindowManagerEventInjectionStrategy strat = new WindowManagerEventInjectionStrategy(); + strat.initialize(); + injector = new EventInjector(strat); + } + } + + @Override + public void tearDown() throws Exception { + super.tearDown(); + } + + @LargeTest + public void testInjectKeyEventUpWithNoDown() throws Exception { + sendActivity = getActivity(); + + getInstrumentation().runOnMainSync(new Runnable() { + @Override + public void run() { + View view = sendActivity.findViewById(R.id.send_data_edit_text); + assertTrue(view.requestFocus()); + latch.countDown(); + } + }); + + assertTrue("Timed out!", latch.await(10, TimeUnit.SECONDS)); + KeyCharacterMap keyCharacterMap = UiControllerImpl.getKeyCharacterMap(); + KeyEvent[] events = keyCharacterMap.getEvents("a".toCharArray()); + assertTrue(injector.injectKeyEvent(events[1])); + } + + @LargeTest + public void testInjectStaleKeyEvent() throws Exception { + sendActivity = getActivity(); + + getInstrumentation().runOnMainSync(new Runnable() { + @Override + public void run() { + View view = sendActivity.findViewById(R.id.send_data_edit_text); + assertTrue(view.requestFocus()); + latch.countDown(); + } + }); + + assertTrue("Timed out!", latch.await(10, TimeUnit.SECONDS)); + assertFalse("SecurityException exception was thrown.", injectEventThrewSecurityException.get()); + + KeyCharacterMap keyCharacterMap = UiControllerImpl.getKeyCharacterMap(); + KeyEvent[] events = keyCharacterMap.getEvents("a".toCharArray()); + KeyEvent event = KeyEvent.changeTimeRepeat(events[0], 1, 0); + + // Stale event does not fail for API < 13. + if (Build.VERSION.SDK_INT < 13) { + assertTrue(injector.injectKeyEvent(event)); + } else { + assertFalse(injector.injectKeyEvent(event)); + } + } + + @LargeTest + public void testInjectKeyEvent_securityException() { + KeyCharacterMap keyCharacterMap = UiControllerImpl.getKeyCharacterMap(); + KeyEvent[] events = keyCharacterMap.getEvents("a".toCharArray()); + try { + injector.injectKeyEvent(events[0]); + fail("Should have thrown a security exception!"); + } catch (InjectEventSecurityException expected) { } + } + + @LargeTest + public void testInjectMotionEvent_securityException() throws Exception { + getInstrumentation().runOnMainSync(new Runnable() { + @Override + public void run() { + MotionEvent down = MotionEvent.obtain(SystemClock.uptimeMillis(), + SystemClock.uptimeMillis(), + MotionEvent.ACTION_DOWN, + 0, + 0, + 0); + try { + injector.injectMotionEvent(down); + } catch (InjectEventSecurityException expected) { + injectEventThrewSecurityException.set(true); + } + latch.countDown(); + } + }); + + latch.await(10, TimeUnit.SECONDS); + assertTrue(injectEventThrewSecurityException.get()); + } + + @LargeTest + public void testInjectMotionEvent_upEventFailure() throws InterruptedException { + final CountDownLatch activityStarted = new CountDownLatch(1); + ActivityLifecycleCallback callback = new ActivityLifecycleCallback() { + @Override + public void onActivityLifecycleChanged(Activity activity, Stage stage) { + if (Stage.RESUMED == stage && activity instanceof SendActivity) { + activityStarted.countDown(); + } + } + }; + ActivityLifecycleMonitorRegistry + .getInstance() + .addLifecycleCallback(callback); + try { + getActivity(); + assertTrue(activityStarted.await(20, TimeUnit.SECONDS)); + final int[] xy = UiControllerImplIntegrationTest.getCoordinatesInMiddleOfSendButton( + getActivity(), getInstrumentation()); + + getInstrumentation().runOnMainSync(new Runnable() { + @Override + public void run() { + MotionEvent up = MotionEvent.obtain(SystemClock.uptimeMillis(), + SystemClock.uptimeMillis(), + MotionEvent.ACTION_UP, + xy[0], + xy[1], + 0); + + try { + injectEventWorked.set(injector.injectMotionEvent(up)); + } catch (InjectEventSecurityException e) { + Log.e(TAG, "injectEvent threw a SecurityException"); + } + up.recycle(); + latch.countDown(); + } + }); + + latch.await(10, TimeUnit.SECONDS); + assertFalse(injectEventWorked.get()); + } finally { + ActivityLifecycleMonitorRegistry + .getInstance() + .removeLifecycleCallback(callback); + } + + } +} diff --git a/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/base/IdlingResourceIntegrationTest.java b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/base/IdlingResourceIntegrationTest.java new file mode 100644 index 0000000..98633f7 --- /dev/null +++ b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/base/IdlingResourceIntegrationTest.java @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.base; + +import static com.google.android.apps.common.testing.ui.espresso.Espresso.onView; +import static com.google.android.apps.common.testing.ui.espresso.Espresso.pressBack; +import static com.google.android.apps.common.testing.ui.espresso.Espresso.registerIdlingResources; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.click; +import static com.google.android.apps.common.testing.ui.espresso.assertion.ViewAssertions.matches; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDisplayed; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.equalToIgnoringCase; +import static org.hamcrest.Matchers.is; + +import com.google.android.apps.common.testing.ui.espresso.IdlingResource; +import com.google.android.apps.common.testing.ui.testapp.SendActivity; + +import android.test.ActivityInstrumentationTestCase2; +import android.test.suitebuilder.annotation.LargeTest; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Integration test with IdlingResources. + */ +@LargeTest +public class IdlingResourceIntegrationTest extends ActivityInstrumentationTestCase2<SendActivity> { + + private ResettingIdlingResource r1; + private ResettingIdlingResource r2; + + @SuppressWarnings("deprecation") + public IdlingResourceIntegrationTest() { + super("com.google.android.apps.common.testing.ui.testapp", SendActivity.class); + } + + @Override + public void setUp() throws Exception { + super.setUp(); + r1 = new ResettingIdlingResource("SlowResource", 6000); + r2 = new ResettingIdlingResource("FastResource", 500); + registerIdlingResources(r1, r2); + getActivity(); + } + + public void testClickWithCustomIdlingResources() { + onView(withText(equalToIgnoringCase("send"))).perform(click()); + r1.reset(); + r2.reset(); + onView(withText(is("Data from sender"))).check(matches(isDisplayed())); + r1.reset(); + r2.reset(); + pressBack(); + r1.reset(); + r2.reset(); + onView(withText(equalToIgnoringCase("send"))).perform(click()); + r1.reset(); + r2.reset(); + pressBack(); + r1.reset(); + r2.reset(); + onView(withText(equalToIgnoringCase("send"))).perform(click()); + } + + private class ResettingIdlingResource implements IdlingResource { + private final String name; + private final long delay; + private final AtomicBoolean isIdle = new AtomicBoolean(false); + private final ScheduledExecutorService pool; + + private ResourceCallback callback; + + public ResettingIdlingResource(String name, long delay) { + this.name = name; + this.delay = delay; + this.pool = Executors.newScheduledThreadPool(1); + } + + @Override + public void registerIdleTransitionCallback(final ResourceCallback callback) { + this.callback = callback; + scheduleDelayedCallback(); + } + + private void scheduleDelayedCallback() { + pool.schedule(new Runnable() { + @Override + public void run() { + callback.onTransitionToIdle(); + isIdle.set(true); + } + }, delay, TimeUnit.MILLISECONDS); + } + + @Override + public boolean isIdleNow() { + return isIdle.get(); + } + + @Override + public String getName() { + return name; + } + + public void reset() { + isIdle.set(false); + scheduleDelayedCallback(); + } + } +} diff --git a/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/base/IdlingResourceRegistryTest.java b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/base/IdlingResourceRegistryTest.java new file mode 100644 index 0000000..aee49a8 --- /dev/null +++ b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/base/IdlingResourceRegistryTest.java @@ -0,0 +1,237 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.base; + +import com.google.android.apps.common.testing.ui.espresso.IdlingResource; +import com.google.android.apps.common.testing.ui.espresso.base.IdlingResourceRegistry.IdleNotificationCallback; + +import android.os.Handler; +import android.os.Looper; +import android.test.InstrumentationTestCase; +import android.test.suitebuilder.annotation.LargeTest; +import android.util.Log; + +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Unit tests for {@link IdlingResourceRegistry}. + */ +public class IdlingResourceRegistryTest extends InstrumentationTestCase { + + private IdlingResourceRegistry registry; + private Handler handler; + + @Override + public void setUp() throws Exception { + Looper looper = Looper.getMainLooper(); + handler = new Handler(looper); + registry = new IdlingResourceRegistry(looper); + } + + public void testRegisterDuplicates() { + IdlingResource r1 = new OnDemandIdlingResource("r1"); + IdlingResource r1dup = new OnDemandIdlingResource("r1"); + registry.register(r1); + registry.register(r1); + registry.register(r1dup); + } + + public void testAllResourcesAreIdle() throws InterruptedException { + OnDemandIdlingResource r1 = new OnDemandIdlingResource("r1"); + OnDemandIdlingResource r2 = new OnDemandIdlingResource("r2"); + IdlingResource r3 = new OnDemandIdlingResource("r3"); + r1.forceIdleNow(); + r2.forceIdleNow(); + registry.register(r1); + registry.register(r2); + final AtomicBoolean resourcesIdle = new AtomicBoolean(false); + final CountDownLatch latch = new CountDownLatch(1); + handler.post(new Runnable() { + @Override + public void run() { + resourcesIdle.set(registry.allResourcesAreIdle()); + latch.countDown(); + } + }); + latch.await(); + assertTrue(resourcesIdle.get()); + + final CountDownLatch latch2 = new CountDownLatch(1); + registry.register(r3); + handler.post(new Runnable() { + @Override + public void run() { + resourcesIdle.set(registry.allResourcesAreIdle()); + latch2.countDown(); + } + }); + latch2.await(); + assertFalse(resourcesIdle.get()); + } + + @LargeTest + public void testAllResourcesAreIdle_RepeatingToIdleTransitions() throws InterruptedException { + OnDemandIdlingResource r1 = new OnDemandIdlingResource("r1"); + registry.register(r1); + final AtomicBoolean resourcesIdle = new AtomicBoolean(false); + for (int i = 1; i <= 3; i++) { + final CountDownLatch latch = new CountDownLatch(1); + handler.post(new Runnable() { + @Override + public void run() { + resourcesIdle.set(registry.allResourcesAreIdle()); + latch.countDown(); + } + }); + latch.await(); + assertFalse("Busy test " + i, resourcesIdle.get()); + + r1.forceIdleNow(); + final CountDownLatch latch2 = new CountDownLatch(1); + handler.post(new Runnable() { + @Override + public void run() { + resourcesIdle.set(registry.allResourcesAreIdle()); + latch2.countDown(); + } + }); + latch2.await(); + assertTrue("Idle transition test " + i, resourcesIdle.get()); + + r1.reset(); + } + } + + @LargeTest + public void testNotifyWhenAllResourcesAreIdle_success() throws InterruptedException { + final CountDownLatch busyWarningLatch = new CountDownLatch(4); + final CountDownLatch timeoutLatch = new CountDownLatch(1); + final CountDownLatch allResourcesIdleLatch = new CountDownLatch(1); + final AtomicReference<List<String>> busysFromWarning = new AtomicReference<List<String>>(); + + OnDemandIdlingResource r1 = new OnDemandIdlingResource("r1"); + OnDemandIdlingResource r2 = new OnDemandIdlingResource("r2"); + OnDemandIdlingResource r3 = new OnDemandIdlingResource("r3"); + registry.register(r1); + registry.register(r2); + registry.register(r3); + + handler.post(new Runnable() { + + @Override + public void run() { + registry.notifyWhenAllResourcesAreIdle(new IdleNotificationCallback() { + private static final String TAG = "IdleNotificationCallback"; + @Override + public void resourcesStillBusyWarning(List<String> busyResourceNames) { + Log.w(TAG, "Timeout warning: " + busyResourceNames); + busysFromWarning.set(busyResourceNames); + busyWarningLatch.countDown(); + } + + @Override + public void resourcesHaveTimedOut(List<String> busyResourceNames) { + Log.w(TAG, "Timeout error: " + busyResourceNames); + timeoutLatch.countDown(); + } + + @Override + public void allResourcesIdle() { + allResourcesIdleLatch.countDown(); + } + }); + } + }); + + assertFalse("Expected to timeout", busyWarningLatch.await(6, TimeUnit.SECONDS)); + assertEquals(3, busysFromWarning.get().size()); + + r3.forceIdleNow(); + assertFalse("Expected to timeout", busyWarningLatch.await(6, TimeUnit.SECONDS)); + assertEquals(2, busysFromWarning.get().size()); + + r2.forceIdleNow(); + assertFalse("Expected to timeout", busyWarningLatch.await(6, TimeUnit.SECONDS)); + assertEquals(1, busysFromWarning.get().size()); + + r1.forceIdleNow(); + assertTrue(allResourcesIdleLatch.await(200, TimeUnit.MILLISECONDS)); + assertEquals(1, busyWarningLatch.getCount()); + assertEquals(1, timeoutLatch.getCount()); + } + + @LargeTest + public void testNotifyWhenAllResourcesAreIdle_timeout() throws InterruptedException { + final CountDownLatch busyWarningLatch = new CountDownLatch(5); + final CountDownLatch timeoutLatch = new CountDownLatch(1); + final CountDownLatch allResourcesIdleLatch = new CountDownLatch(1); + final AtomicReference<List<String>> busysFromWarning = new AtomicReference<List<String>>(); + + OnDemandIdlingResource r1 = new OnDemandIdlingResource("r1"); + OnDemandIdlingResource r2 = new OnDemandIdlingResource("r2"); + OnDemandIdlingResource r3 = new OnDemandIdlingResource("r3"); + registry.register(r1); + registry.register(r2); + registry.register(r3); + + handler.post(new Runnable() { + @Override + public void run() { + registry.notifyWhenAllResourcesAreIdle(new IdleNotificationCallback() { + private static final String TAG = "IdleNotificationCallback"; + @Override + public void resourcesStillBusyWarning(List<String> busyResourceNames) { + Log.w(TAG, "Timeout warning: " + busyResourceNames); + busysFromWarning.set(busyResourceNames); + busyWarningLatch.countDown(); + } + + @Override + public void resourcesHaveTimedOut(List<String> busyResourceNames) { + Log.w(TAG, "Timeout error: " + busyResourceNames); + timeoutLatch.countDown(); + } + + @Override + public void allResourcesIdle() { + allResourcesIdleLatch.countDown(); + } + }); + } + }); + + assertFalse("Expected to timeout", busyWarningLatch.await(6, TimeUnit.SECONDS)); + assertEquals(3, busysFromWarning.get().size()); + + r1.forceIdleNow(); + assertFalse("Expected to timeout", busyWarningLatch.await(6, TimeUnit.SECONDS)); + assertEquals(2, busysFromWarning.get().size()); + + r2.forceIdleNow(); + assertFalse("Expected to timeout", busyWarningLatch.await(6, TimeUnit.SECONDS)); + assertEquals(1, busysFromWarning.get().size()); + + assertTrue("Expected to finish count down", busyWarningLatch.await(8, TimeUnit.SECONDS)); + assertTrue("Should have timed out", timeoutLatch.await(2, TimeUnit.SECONDS)); + assertEquals(1, busysFromWarning.get().size()); + assertEquals(1, allResourcesIdleLatch.getCount()); + } +} diff --git a/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/base/OnDemandIdlingResource.java b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/base/OnDemandIdlingResource.java new file mode 100644 index 0000000..ac0a5a7 --- /dev/null +++ b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/base/OnDemandIdlingResource.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.base; + +import com.google.android.apps.common.testing.ui.espresso.IdlingResource; + +/** + * An {@link IdlingResource} for testing that becomes idle on demand. + */ +public class OnDemandIdlingResource implements IdlingResource { + private final String name; + + private boolean isIdle = false; + private ResourceCallback callback; + + public OnDemandIdlingResource(String name) { + this.name = name; + } + + @Override + public void registerIdleTransitionCallback(ResourceCallback callback) { + this.callback = callback; + } + + @Override + public boolean isIdleNow() { + return isIdle; + } + + @Override + public String getName() { + return name; + } + + public void forceIdleNow() { + isIdle = true; + if (callback != null) { + callback.onTransitionToIdle(); + } + } + + public void reset() { + isIdle = false; + } +} diff --git a/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/base/UiControllerImplIntegrationTest.java b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/base/UiControllerImplIntegrationTest.java new file mode 100644 index 0000000..02a3fda --- /dev/null +++ b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/base/UiControllerImplIntegrationTest.java @@ -0,0 +1,295 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.base; + +import com.google.android.apps.common.testing.ui.espresso.InjectEventSecurityException; +import com.google.android.apps.common.testing.ui.espresso.UiController; +import com.google.android.apps.common.testing.ui.espresso.util.HumanReadables; +import com.google.android.apps.common.testing.ui.testapp.R; +import com.google.android.apps.common.testing.ui.testapp.SendActivity; +import com.google.common.base.Optional; + +import android.app.Activity; +import android.app.Instrumentation; +import android.os.Build; +import android.os.Looper; +import android.os.SystemClock; +import android.test.ActivityInstrumentationTestCase2; +import android.test.suitebuilder.annotation.LargeTest; +import android.util.Log; +import android.view.KeyCharacterMap; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.View; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Test for {@link UiControllerImpl}. + */ +public class UiControllerImplIntegrationTest + extends ActivityInstrumentationTestCase2<SendActivity> { + private Activity sendActivity; + private final AtomicBoolean injectEventWorked = new AtomicBoolean(false); + private final AtomicBoolean injectEventThrewSecurityException = new AtomicBoolean(false); + private final CountDownLatch focusLatch = new CountDownLatch(1); + private final CountDownLatch latch = new CountDownLatch(1); + private UiController uiController; + + @SuppressWarnings("deprecation") + public UiControllerImplIntegrationTest() { + // Supporting froyo. + super("com.google.android.apps.common.testing.ui.testapp", SendActivity.class); + } + + @Override + public void setUp() throws Exception { + super.setUp(); + EventInjector injector = null; + if (Build.VERSION.SDK_INT > 15) { + InputManagerEventInjectionStrategy strat = new InputManagerEventInjectionStrategy(); + strat.initialize(); + injector = new EventInjector(strat); + } else { + WindowManagerEventInjectionStrategy strat = new WindowManagerEventInjectionStrategy(); + strat.initialize(); + injector = new EventInjector(strat); + } + uiController = new UiControllerImpl( + injector, + new AsyncTaskPoolMonitor(new ThreadPoolExecutorExtractor( + Looper.getMainLooper()).getAsyncTaskThreadPool()), + Optional.<AsyncTaskPoolMonitor>absent(), + new IdlingResourceRegistry(Looper.getMainLooper()), + Looper.getMainLooper()); + } + + + @Override + public SendActivity getActivity() { + SendActivity a = super.getActivity(); + + while (!a.hasWindowFocus()) { + getInstrumentation().waitForIdleSync(); + } + + return a; + } + + @LargeTest + public void testInjectKeyEvent() throws InterruptedException { + sendActivity = getActivity(); + getInstrumentation().waitForIdleSync(); + + getInstrumentation().runOnMainSync(new Runnable() { + @Override + public void run() { + try { + KeyCharacterMap keyCharacterMap = UiControllerImpl.getKeyCharacterMap(); + KeyEvent[] events = keyCharacterMap.getEvents("a".toCharArray()); + injectEventWorked.set(uiController.injectKeyEvent(events[0])); + latch.countDown(); + } catch (InjectEventSecurityException e) { + injectEventThrewSecurityException.set(true); + } + } + }); + + assertFalse("injectEvent threw a SecurityException", injectEventThrewSecurityException.get()); + assertTrue("Timed out!", latch.await(10, TimeUnit.SECONDS)); + assertTrue(injectEventWorked.get()); + } + + @LargeTest + public void testInjectString() throws InterruptedException { + sendActivity = getActivity(); + getInstrumentation().waitForIdleSync(); + final AtomicBoolean requestFocusSucceded = new AtomicBoolean(false); + + getInstrumentation().runOnMainSync(new Runnable() { + @Override + public void run() { + final View view = sendActivity.findViewById(R.id.send_data_to_call_edit_text); + Log.i("TEST", HumanReadables.describe(view)); + requestFocusSucceded.set(view.requestFocus() && view.hasWindowFocus()); + Log.i("TEST-post", HumanReadables.describe(view)); + focusLatch.countDown(); + } + }); + + getInstrumentation().waitForIdleSync(); + assertTrue("requestFocus timed out!", focusLatch.await(2, TimeUnit.SECONDS)); + assertTrue("requestFocus failed.", requestFocusSucceded.get()); + + getInstrumentation().runOnMainSync(new Runnable() { + @Override + public void run() { + try { + injectEventWorked.set(uiController.injectString("Hello! \n&*$$$")); + latch.countDown(); + } catch (InjectEventSecurityException e) { + injectEventThrewSecurityException.set(true); + } + } + }); + + assertFalse("SecurityException exception was thrown.", injectEventThrewSecurityException.get()); + assertTrue("Timed out!", latch.await(20, TimeUnit.SECONDS)); + assertTrue(injectEventWorked.get()); + } + + @LargeTest + public void testInjectLargeString() throws InterruptedException { + sendActivity = getActivity(); + getInstrumentation().waitForIdleSync(); + final AtomicBoolean requestFocusSucceded = new AtomicBoolean(false); + + getInstrumentation().runOnMainSync(new Runnable() { + @Override + public void run() { + final View view = sendActivity.findViewById(R.id.send_data_to_call_edit_text); + Log.i("TEST", HumanReadables.describe(view)); + requestFocusSucceded.set(view.requestFocus()); + Log.i("TEST-post", HumanReadables.describe(view)); + + focusLatch.countDown(); + } + }); + + assertTrue("requestFocus timed out!", focusLatch.await(2, TimeUnit.SECONDS)); + assertTrue("requestFocus failed.", requestFocusSucceded.get()); + + getInstrumentation().runOnMainSync(new Runnable() { + @Override + public void run() { + try { + injectEventWorked.set(uiController.injectString("This is a string with 32 chars!!")); + latch.countDown(); + } catch (InjectEventSecurityException e) { + injectEventThrewSecurityException.set(true); + } + } + }); + + assertFalse("SecurityException exception was thrown.", injectEventThrewSecurityException.get()); + assertTrue("Timed out!", latch.await(20, TimeUnit.SECONDS)); + assertTrue(injectEventWorked.get()); + } + + @LargeTest + public void testInjectEmptyString() throws InterruptedException { + sendActivity = getActivity(); + getInstrumentation().waitForIdleSync(); + final AtomicBoolean requestFocusSucceded = new AtomicBoolean(false); + + getInstrumentation().runOnMainSync(new Runnable() { + @Override + public void run() { + final View view = sendActivity.findViewById(R.id.send_data_to_call_edit_text); + requestFocusSucceded.set(view.requestFocus()); + focusLatch.countDown(); + } + }); + + assertTrue("requestFocus timed out!", focusLatch.await(2, TimeUnit.SECONDS)); + assertTrue("requestFocus failed.", requestFocusSucceded.get()); + + getInstrumentation().runOnMainSync(new Runnable() { + @Override + public void run() { + try { + injectEventWorked.set(uiController.injectString("")); + latch.countDown(); + } catch (InjectEventSecurityException e) { + injectEventThrewSecurityException.set(true); + } + } + }); + + assertFalse("SecurityException exception was thrown.", injectEventThrewSecurityException.get()); + assertTrue("Timed out!", latch.await(20, TimeUnit.SECONDS)); + assertTrue(injectEventWorked.get()); + } + + @LargeTest + public void testInjectStringSecurityException() throws InterruptedException { + getInstrumentation().runOnMainSync(new Runnable() { + @Override + public void run() { + try { + injectEventWorked.set(uiController.injectString("Hello! \n&*$$$")); + latch.countDown(); + } catch (InjectEventSecurityException e) { + injectEventThrewSecurityException.set(true); + } + } + }); + + assertTrue("SecurityException exception was thrown.", injectEventThrewSecurityException.get()); + assertFalse("Did NOT time out!", latch.await(3, TimeUnit.SECONDS)); + assertFalse(injectEventWorked.get()); + } + + @LargeTest + public void testInjectMotionEvent() throws InterruptedException { + sendActivity = getActivity(); + final int xy[] = getCoordinatesInMiddleOfSendButton(sendActivity, getInstrumentation()); + + getInstrumentation().runOnMainSync(new Runnable() { + @Override + public void run() { + long downTime = SystemClock.uptimeMillis(); + try { + MotionEvent event = MotionEvent.obtain(downTime, + SystemClock.uptimeMillis(), + MotionEvent.ACTION_DOWN, + xy[0], + xy[1], + 0); + + injectEventWorked.set(uiController.injectMotionEvent(event)); + event.recycle(); + latch.countDown(); + } catch (InjectEventSecurityException e) { + injectEventThrewSecurityException.set(true); + } + } + }); + + assertFalse("SecurityException exception was thrown.", injectEventThrewSecurityException.get()); + assertTrue("Timed out!", latch.await(10, TimeUnit.SECONDS)); + assertTrue(injectEventWorked.get()); + } + + static int[] getCoordinatesInMiddleOfSendButton( + Activity activity, Instrumentation instrumentation) { + final View sendButton = activity.findViewById(R.id.send_button); + final int[] xy = new int[2]; + instrumentation.runOnMainSync(new Runnable() { + @Override + public void run() { + sendButton.getLocationOnScreen(xy); + } + }); + int x = xy[0] + (sendButton.getWidth() / 2); + int y = xy[1] + (sendButton.getHeight() / 2); + int[] xyMiddle = {x, y}; + return xyMiddle; + } +} diff --git a/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/base/UiControllerImplTest.java b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/base/UiControllerImplTest.java new file mode 100644 index 0000000..2b95fc8 --- /dev/null +++ b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/base/UiControllerImplTest.java @@ -0,0 +1,390 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.base; + +import com.google.android.apps.common.testing.ui.espresso.IdlingResourceTimeoutException; +import com.google.common.base.Optional; + +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.test.suitebuilder.annotation.LargeTest; +import android.util.Log; + +import junit.framework.TestCase; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Unit test for {@link UiControllerImpl}. + */ +public class UiControllerImplTest extends TestCase { + + private static final String TAG = UiControllerImplTest.class.getSimpleName(); + + private LooperThread testThread; + private AtomicReference<UiControllerImpl> uiController = new AtomicReference<UiControllerImpl>(); + private ThreadPoolExecutor asyncPool; + private IdlingResourceRegistry idlingResourceRegistry; + + private static class LooperThread extends Thread { + private final CountDownLatch init = new CountDownLatch(1); + private Handler handler; + private Looper looper; + + @Override + public void run() { + Looper.prepare(); + handler = new Handler(); + looper = Looper.myLooper(); + init.countDown(); + Looper.loop(); + } + + public void quitLooper() { + looper.quit(); + } + + public Looper getLooper() { + try { + init.await(); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + return looper; + } + + public Handler getHandler() { + try { + init.await(); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + return handler; + } + } + + @Override + public void setUp() throws Exception { + super.setUp(); + testThread = new LooperThread(); + testThread.setUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() { + @Override + public void uncaughtException(Thread thread, Throwable ex) { + Log.e(TAG, "Looper died: ", ex); + } + }); + testThread.start(); + idlingResourceRegistry = new IdlingResourceRegistry(testThread.getLooper()); + asyncPool = new ThreadPoolExecutor(3, 3, 1, TimeUnit.SECONDS, + new LinkedBlockingQueue<Runnable>()); + EventInjector injector = null; + if (Build.VERSION.SDK_INT > 15) { + InputManagerEventInjectionStrategy strat = new InputManagerEventInjectionStrategy(); + strat.initialize(); + injector = new EventInjector(strat); + } else { + WindowManagerEventInjectionStrategy strat = new WindowManagerEventInjectionStrategy(); + strat.initialize(); + injector = new EventInjector(strat); + } + uiController.set(new UiControllerImpl( + injector, + new AsyncTaskPoolMonitor(asyncPool), + Optional.<AsyncTaskPoolMonitor>absent(), + idlingResourceRegistry, + testThread.getLooper() + )); + + + } + + @Override + public void tearDown() throws Exception { + testThread.quitLooper(); + asyncPool.shutdown(); + super.tearDown(); + } + + public void testLoopMainThreadTillIdle_sendsMessageToRightHandler() { + final CountDownLatch latch = new CountDownLatch(3); + testThread.getHandler(); // blocks till initialized; + final Handler firstHandler = new Handler( + testThread.looper, + new Handler.Callback() { + private boolean counted = false; + @Override + public boolean handleMessage(Message me) { + if (counted) { + fail("Called 2x!!!!"); + } + counted = true; + latch.countDown(); + return true; + } + }); + + final Handler secondHandler = new Handler( + testThread.looper, + new Handler.Callback() { + private boolean counted = false; + @Override + public boolean handleMessage(Message me) { + if (counted) { + fail("Called 2x!!!!"); + } + counted = true; + latch.countDown(); + return true; + } + }); + + assertTrue(testThread.getHandler().post(new Runnable() { + @Override + public void run() { + firstHandler.sendEmptyMessage(1); + secondHandler.sendEmptyMessage(1); + uiController.get().loopMainThreadUntilIdle(); + + latch.countDown(); + } + })); + + try { + assertTrue( + "Timed out waiting for looper to process all events", latch.await(10, TimeUnit.SECONDS)); + } catch (InterruptedException e) { + fail("Failed with exception " + e); + } + } + + public void testLoopForAtLeast() throws Exception { + final CountDownLatch latch = new CountDownLatch(2); + assertTrue(testThread.getHandler().post(new Runnable() { + @Override + public void run() { + testThread.getHandler().post(new Runnable() { + @Override + public void run() { + latch.countDown(); + } + + }); + uiController.get().loopMainThreadForAtLeast(1000); + latch.countDown(); + } + })); + assertTrue("Never returned from UiControllerImpl.loopMainThreadForAtLeast();", + latch.await(10, TimeUnit.SECONDS)); + } + + public void testLoopMainThreadUntilIdle_fullQueue() { + final CountDownLatch latch = new CountDownLatch(3); + assertTrue(testThread.getHandler().post(new Runnable() { + @Override + public void run() { + Log.i(TAG, "On main thread"); + Handler handler = new Handler(); + Log.i(TAG, "Equeueing test runnable 1"); + handler.post(new Runnable() { + @Override + public void run() { + Log.i(TAG, "Running test runnable 1"); + latch.countDown(); + } + }); + Log.i(TAG, "Equeueing test runnable 2"); + handler.post(new Runnable() { + @Override + public void run() { + Log.i(TAG, "Running test runnable 2"); + latch.countDown(); + } + }); + Log.i(TAG, "Hijacking thread and looping it."); + uiController.get().loopMainThreadUntilIdle(); + latch.countDown(); + } + })); + + try { + assertTrue( + "Timed out waiting for looper to process all events", latch.await(10, TimeUnit.SECONDS)); + } catch (InterruptedException e) { + fail("Failed with exception " + e); + } + } + + public void testLoopMainThreadUntilIdle_fullQueueAndAsyncTasks() throws Exception { + final CountDownLatch latch = new CountDownLatch(3); + final CountDownLatch asyncTaskStarted = new CountDownLatch(1); + final CountDownLatch asyncTaskShouldComplete = new CountDownLatch(1); + asyncPool.execute(new Runnable() { + @Override + public void run() { + asyncTaskStarted.countDown(); + while (true) { + try { + asyncTaskShouldComplete.await(); + return; + } catch (InterruptedException ie) { + // cant interrupt me. ignore. + } + } + } + }); + assertTrue("async task is not starting!", asyncTaskStarted.await(2, TimeUnit.SECONDS)); + + assertTrue(testThread.getHandler().post(new Runnable() { + @Override + public void run() { + Log.i(TAG, "On main thread"); + Handler handler = new Handler(); + Log.i(TAG, "Equeueing test runnable 1"); + handler.post(new Runnable() { + @Override + public void run() { + Log.i(TAG, "Running test runnable 1"); + latch.countDown(); + } + }); + Log.i(TAG, "Equeueing test runnable 2"); + handler.post(new Runnable() { + @Override + public void run() { + Log.i(TAG, "Running test runnable 2"); + latch.countDown(); + } + }); + Log.i(TAG, "Hijacking thread and looping it."); + uiController.get().loopMainThreadUntilIdle(); + latch.countDown(); + } + })); + assertFalse( + "Should not have stopped looping the main thread yet!", latch.await(2, TimeUnit.SECONDS)); + assertEquals("Not all main thread tasks have checked in", 1L, latch.getCount()); + asyncTaskShouldComplete.countDown(); + assertTrue("App should be idle.", latch.await(5, TimeUnit.SECONDS)); + } + + + public void testLoopMainThreadUntilIdle_emptyQueue() { + final CountDownLatch latch = new CountDownLatch(1); + assertTrue(testThread.getHandler().post(new Runnable() { + @Override + public void run() { + uiController.get().loopMainThreadUntilIdle(); + latch.countDown(); + } + })); + try { + assertTrue("Never returned from UiControllerImpl.loopMainThreadUntilIdle();", + latch.await(10, TimeUnit.SECONDS)); + } catch (InterruptedException e) { + fail("Failed with exception " + e); + } + } + + public void testLoopMainThreadUntilIdle_oneIdlingResource() throws InterruptedException { + OnDemandIdlingResource fakeResource = new OnDemandIdlingResource("FakeResource"); + idlingResourceRegistry.register(fakeResource); + final CountDownLatch latch = new CountDownLatch(1); + assertTrue(testThread.getHandler().post(new Runnable() { + @Override + public void run() { + Log.i(TAG, "Hijacking thread and looping it."); + uiController.get().loopMainThreadUntilIdle(); + latch.countDown(); + } + })); + assertFalse( + "Should not have stopped looping the main thread yet!", latch.await(2, TimeUnit.SECONDS)); + fakeResource.forceIdleNow(); + assertTrue("App should be idle.", latch.await(5, TimeUnit.SECONDS)); + } + + public void testLoopMainThreadUntilIdle_multipleIdlingResources() throws InterruptedException { + OnDemandIdlingResource fakeResource1 = new OnDemandIdlingResource("FakeResource1"); + OnDemandIdlingResource fakeResource2 = new OnDemandIdlingResource("FakeResource2"); + OnDemandIdlingResource fakeResource3 = new OnDemandIdlingResource("FakeResource3"); + // Register the first two right away and one later (once the wait for the first two begins). + idlingResourceRegistry.register(fakeResource1); + idlingResourceRegistry.register(fakeResource2); + final CountDownLatch latch = new CountDownLatch(1); + assertTrue(testThread.getHandler().post(new Runnable() { + @Override + public void run() { + Log.i(TAG, "Hijacking thread and looping it."); + uiController.get().loopMainThreadUntilIdle(); + latch.countDown(); + } + })); + assertFalse( + "Should not have stopped looping the main thread yet!", latch.await(1, TimeUnit.SECONDS)); + fakeResource1.forceIdleNow(); + assertFalse( + "Should not have stopped looping the main thread yet!", latch.await(1, TimeUnit.SECONDS)); + idlingResourceRegistry.register(fakeResource3); + assertFalse( + "Should not have stopped looping the main thread yet!", latch.await(1, TimeUnit.SECONDS)); + fakeResource2.forceIdleNow(); + assertFalse( + "Should not have stopped looping the main thread yet!", latch.await(1, TimeUnit.SECONDS)); + fakeResource3.forceIdleNow(); + assertTrue("App should be idle.", latch.await(5, TimeUnit.SECONDS)); + } + + @LargeTest + public void testLoopMainThreadUntilIdle_timeout() throws InterruptedException { + OnDemandIdlingResource goodResource = + new OnDemandIdlingResource("GoodResource"); + OnDemandIdlingResource kindaCrappyResource = + new OnDemandIdlingResource("KindaCrappyResource"); + OnDemandIdlingResource badResource = + new OnDemandIdlingResource("VeryBadResource"); + idlingResourceRegistry.register(goodResource); + idlingResourceRegistry.register(kindaCrappyResource); + idlingResourceRegistry.register(badResource); + final CountDownLatch latch = new CountDownLatch(1); + assertTrue(testThread.getHandler().post(new Runnable() { + @Override + public void run() { + Log.i(TAG, "Hijacking thread and looping it."); + try { + uiController.get().loopMainThreadUntilIdle(); + } catch (IdlingResourceTimeoutException e) { + latch.countDown(); + } + } + })); + assertFalse( + "Should not have stopped looping the main thread yet!", latch.await(4, TimeUnit.SECONDS)); + goodResource.forceIdleNow(); + assertFalse( + "Should not have stopped looping the main thread yet!", latch.await(12, TimeUnit.SECONDS)); + kindaCrappyResource.forceIdleNow(); + assertTrue( + "Should have caught IdlingResourceTimeoutException", latch.await(11, TimeUnit.SECONDS)); + } + +} diff --git a/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/base/ViewFinderImplTest.java b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/base/ViewFinderImplTest.java new file mode 100644 index 0000000..7810a83 --- /dev/null +++ b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/base/ViewFinderImplTest.java @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.base; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.sameInstance; + +import com.google.android.apps.common.testing.ui.espresso.AmbiguousViewMatcherException; +import com.google.android.apps.common.testing.ui.espresso.NoMatchingViewException; +import com.google.android.apps.common.testing.ui.espresso.ViewFinder; + +import android.test.InstrumentationTestCase; +import android.test.UiThreadTest; +import android.view.View; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import org.hamcrest.Matchers; + +import javax.inject.Provider; + +/** Unit tests for {@link ViewFinderImpl}. */ +public class ViewFinderImplTest extends InstrumentationTestCase { + private Provider<View> testViewProvider; + private RelativeLayout testView; + private View child1; + private View child2; + private View child3; + private View child4; + private View nestedChild; + + @Override + public void setUp() throws Exception { + super.setUp(); + testView = new RelativeLayout(getInstrumentation().getTargetContext()); + child1 = new TextView(getInstrumentation().getTargetContext()); + child1.setId(1); + child2 = new TextView(getInstrumentation().getTargetContext()); + child2.setId(2); + child3 = new TextView(getInstrumentation().getTargetContext()); + child3.setId(3); + child4 = new TextView(getInstrumentation().getTargetContext()); + child4.setId(4); + nestedChild = new TextView(getInstrumentation().getTargetContext()); + nestedChild.setId(5); + RelativeLayout nestingLayout = new RelativeLayout(getInstrumentation().getTargetContext()); + nestingLayout.addView(nestedChild); + testView.addView(child1); + testView.addView(child2); + testView.addView(nestingLayout); + testView.addView(child3); + testView.addView(child4); + testViewProvider = new Provider<View>() { + @Override + public View get() { + return testView; + } + + @Override + public String toString() { + return "of(" + testView + ")"; + } + }; + } + + @UiThreadTest + public void testGetView_present() { + ViewFinder finder = new ViewFinderImpl(sameInstance(nestedChild), testViewProvider); + assertThat(finder.getView(), sameInstance(nestedChild)); + } + + @UiThreadTest + public void testGetView_missing() { + ViewFinder finder = new ViewFinderImpl(Matchers.<View>nullValue(), testViewProvider); + try { + finder.getView(); + fail("No children should pass that matcher!"); + } catch (NoMatchingViewException expected) {} + } + + @UiThreadTest + public void testGetView_multiple() { + ViewFinder finder = new ViewFinderImpl(Matchers.<View>notNullValue(), testViewProvider); + try { + finder.getView(); + fail("All nodes hit that matcher!"); + } catch (AmbiguousViewMatcherException expected) {} + } + + public void testFind_offUiThread() { + ViewFinder finder = new ViewFinderImpl(sameInstance(nestedChild), testViewProvider); + try { + finder.getView(); + fail("not on main thread, should die."); + } catch (IllegalStateException expected) {} + } + +} diff --git a/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/contrib/CountingIdlingResourceTest.java b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/contrib/CountingIdlingResourceTest.java new file mode 100644 index 0000000..8bd2d11 --- /dev/null +++ b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/contrib/CountingIdlingResourceTest.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.contrib; + +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.MockitoAnnotations.initMocks; + +import com.google.android.apps.common.testing.ui.espresso.IdlingResource.ResourceCallback; + +import android.test.InstrumentationTestCase; + +import org.mockito.Mock; + +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.FutureTask; + +/** Unit tests for {@link CountingIdlingResource}. */ +public class CountingIdlingResourceTest extends InstrumentationTestCase { + + private static final String RESOURCE_NAME = "test_resource"; + private CountingIdlingResource resource; + + @Mock + private ResourceCallback mockCallback; + + @Override + public void setUp() throws Exception { + super.setUp(); + initMocks(this); + resource = new CountingIdlingResource(RESOURCE_NAME, true); + } + + public void testResourceName() { + assertEquals(RESOURCE_NAME, resource.getName()); + } + + public void testInvalidStateDetected() throws Exception { + resource.increment(); + resource.decrement(); + try { + resource.decrement(); + fail("Should throw illegal state exception!"); + } catch (IllegalStateException expected) { } + } + + public void testIsIdle() throws Exception { + assertTrue(callIsIdle()); + resource.increment(); + assertFalse(callIsIdle()); + resource.decrement(); + assertTrue(callIsIdle()); + } + + public void testIdleNotification() throws Exception { + registerIdleCallback(); + assertTrue(callIsIdle()); + verify(mockCallback, never()).onTransitionToIdle(); + + resource.increment(); + verify(mockCallback, never()).onTransitionToIdle(); + assertFalse(callIsIdle()); + + resource.decrement(); + verify(mockCallback).onTransitionToIdle(); + assertTrue(callIsIdle()); + } + + private void registerIdleCallback() throws Exception { + FutureTask<Void> registerTask = new FutureTask<Void>(new Callable<Void>() { + @Override + public Void call() throws Exception { + resource.registerIdleTransitionCallback(mockCallback); + return null; + } + + }); + getInstrumentation().runOnMainSync(registerTask); + try { + registerTask.get(); + } catch (ExecutionException ee) { + throw new RuntimeException(ee.getCause()); + } + + } + + private boolean callIsIdle() throws Exception { + FutureTask<Boolean> isIdleTask = new FutureTask<Boolean>(new IsIdleCallable()); + getInstrumentation().runOnMainSync(isIdleTask); + try { + return isIdleTask.get(); + } catch (ExecutionException ee) { + throw new RuntimeException(ee.getCause()); + } + } + + + private class IsIdleCallable implements Callable<Boolean> { + @Override + public Boolean call() throws Exception { + return resource.isIdleNow(); + } + } + +} diff --git a/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/matcher/PreferenceMatchersTest.java b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/matcher/PreferenceMatchersTest.java new file mode 100644 index 0000000..1501184 --- /dev/null +++ b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/matcher/PreferenceMatchersTest.java @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.matcher; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + + +import static com.google.android.apps.common.testing.ui.espresso.matcher.PreferenceMatchers.withKey; +import static com.google.android.apps.common.testing.ui.espresso.matcher.PreferenceMatchers.withSummary; +import static com.google.android.apps.common.testing.ui.espresso.matcher.PreferenceMatchers.withSummaryText; +import static com.google.android.apps.common.testing.ui.espresso.matcher.PreferenceMatchers.withTitle; +import static com.google.android.apps.common.testing.ui.espresso.matcher.PreferenceMatchers.withTitleText; +import static com.google.android.apps.common.testing.ui.espresso.matcher.PreferenceMatchers.isEnabled; +import static org.hamcrest.Matchers.not; + +import com.google.android.apps.common.testing.ui.testapp.test.R; + +import android.test.InstrumentationTestCase; +import android.preference.CheckBoxPreference; +import android.preference.EditTextPreference; + +/** + * Unit tests for preference matchers. + */ +public class PreferenceMatchersTest extends InstrumentationTestCase { + + + public void testWithSummary() { + CheckBoxPreference pref = new CheckBoxPreference(getInstrumentation().getContext()); + pref.setSummary(R.string.something); + assertThat(pref, withSummary(R.string.something)); + assertThat(pref, not(withSummary(R.string.other_string))); + assertThat(pref, withSummaryText("Hello World")); + assertThat(pref, not(withSummaryText(("Hello Mars")))); + assertThat(pref, withSummaryText(is("Hello World"))); + } + + public void testWithTitle() { + CheckBoxPreference pref = new CheckBoxPreference(getInstrumentation().getContext()); + pref.setTitle(R.string.other_string); + assertThat(pref, withTitle(R.string.other_string)); + assertThat(pref, not(withTitle(R.string.something))); + assertThat(pref, withTitleText("Goodbye!!")); + assertThat(pref, not(withTitleText(("Hello Mars")))); + assertThat(pref, withTitleText(is("Goodbye!!"))); + } + + + public void testIsEnabled() { + CheckBoxPreference pref = new CheckBoxPreference(getInstrumentation().getContext()); + pref.setEnabled(true); + assertThat(pref, isEnabled()); + pref.setEnabled(false); + assertThat(pref, not(isEnabled())); + EditTextPreference pref2 = new EditTextPreference(getInstrumentation().getContext()); + pref2.setEnabled(true); + assertThat(pref2, isEnabled()); + pref2.setEnabled(false); + assertThat(pref2, not(isEnabled())); + } + + public void testWithKey() { + CheckBoxPreference pref = new CheckBoxPreference(getInstrumentation().getContext()); + pref.setKey("foo"); + assertThat(pref, withKey("foo")); + assertThat(pref, not(withKey("bar"))); + assertThat(pref, withKey(is("foo"))); + } +} diff --git a/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/matcher/ViewMatchersTest.java b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/matcher/ViewMatchersTest.java new file mode 100644 index 0000000..5000e46 --- /dev/null +++ b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/matcher/ViewMatchersTest.java @@ -0,0 +1,456 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.matcher; + +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.hasContentDescription; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.hasDescendant; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.hasImeAction; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.hasSibling; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isAssignableFrom; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isChecked; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isClickable; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDescendantOfA; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isEnabled; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isFocusable; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isNotChecked; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isRoot; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.supportsInputMethods; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withChild; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withContentDescription; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withEffectiveVisibility; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withId; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withParent; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withTagKey; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withTagValue; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.is; + +import com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.Visibility; +import com.google.android.apps.common.testing.ui.testapp.test.R; + +import android.test.InstrumentationTestCase; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.EditorInfo; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.Checkable; +import android.widget.CheckedTextView; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.RadioButton; +import android.widget.RelativeLayout; +import android.widget.ScrollView; +import android.widget.Spinner; +import android.widget.TextView; + +import org.hamcrest.Matcher; +import org.hamcrest.Matchers; + +/** + * Unit tests for {@link ViewMatchers}. + */ +public class ViewMatchersTest extends InstrumentationTestCase { + public void testIsAssignableFrom_notAnInstance() { + View v = new View(getInstrumentation().getTargetContext()); + assertFalse(isAssignableFrom(Spinner.class).matches(v)); + } + + public void testIsAssignableFrom_plainView() { + View v = new View(getInstrumentation().getTargetContext()); + assertTrue(isAssignableFrom(View.class).matches(v)); + } + + public void testIsAssignableFrom_superclass() { + View v = new RadioButton(getInstrumentation().getTargetContext()); + assertTrue(isAssignableFrom(Button.class).matches(v)); + } + + @SuppressWarnings("cast") + public void testWithContentDescriptionCharSequence() { + View view = new View(getInstrumentation().getTargetContext()); + view.setContentDescription(null); + assertTrue(withContentDescription(Matchers.<CharSequence>nullValue()).matches(view)); + CharSequence testText = "test text!"; + view.setContentDescription(testText); + assertTrue(withContentDescription(is(testText)).matches(view)); + assertFalse(withContentDescription(is((CharSequence) "blah")).matches(view)); + assertFalse(withContentDescription(is((CharSequence) "")).matches(view)); + } + + public void testWithContentDescriptionNull() { + try { + withContentDescription((Matcher<CharSequence>) null); + fail("Should of thrown NPE"); + } catch (NullPointerException e) { + // Good, this is expected. + } + } + + public void testHasContentDescription() { + View view = new View(getInstrumentation().getTargetContext()); + view.setContentDescription(null); + assertFalse(hasContentDescription().matches(view)); + CharSequence testText = "test text!"; + view.setContentDescription(testText); + assertTrue(hasContentDescription().matches(view)); + } + + public void testWithContentDescriptionString() { + View view = new View(getInstrumentation().getTargetContext()); + view.setContentDescription(null); + assertTrue(withContentDescription(Matchers.<String>nullValue()).matches(view)); + String testText = "test text!"; + view.setContentDescription(testText); + assertTrue(withContentDescription(is(testText)).matches(view)); + assertFalse(withContentDescription(is("blah")).matches(view)); + assertFalse(withContentDescription(is("")).matches(view)); + } + + public void testWithId() { + View view = new View(getInstrumentation().getTargetContext()); + view.setId(R.id.testId1); + assertTrue(withId(is(R.id.testId1)).matches(view)); + assertFalse(withId(is(R.id.testId2)).matches(view)); + assertFalse(withId(is(1234)).matches(view)); + } + + public void testWithTagNull() { + try { + withTagKey(0, null); + fail("Should of thrown NPE"); + } catch (NullPointerException e) { + // Good, this is expected. + } + + try { + withTagValue(null); + fail("Should of thrown NPE"); + } catch (NullPointerException e) { + // Good, this is expected. + } + } + + public void testWithTagObject() { + View view = new View(getInstrumentation().getTargetContext()); + view.setTag(null); + assertTrue(withTagValue(Matchers.<Object>nullValue()).matches(view)); + String testObjectText = "test text!"; + view.setTag(testObjectText); + assertFalse(withTagKey(R.id.testId1).matches(view)); + assertTrue(withTagValue(is((Object) testObjectText)).matches(view)); + assertFalse(withTagValue(is((Object) "blah")).matches(view)); + assertFalse(withTagValue(is((Object) "")).matches(view)); + } + + public void testWithTagKey() { + View view = new View(getInstrumentation().getTargetContext()); + assertFalse(withTagKey(R.id.testId1).matches(view)); + view.setTag(R.id.testId1, "blah"); + assertFalse(withTagValue(is((Object) "blah")).matches(view)); + assertTrue(withTagKey(R.id.testId1).matches(view)); + assertFalse(withTagKey(R.id.testId2).matches(view)); + assertFalse(withTagKey(R.id.testId3).matches(view)); + assertFalse(withTagKey(65535).matches(view)); + + view.setTag(R.id.testId2, "blah2"); + assertTrue(withTagKey(R.id.testId1).matches(view)); + assertTrue(withTagKey(R.id.testId2).matches(view)); + assertFalse(withTagKey(R.id.testId3).matches(view)); + assertFalse(withTagKey(65535).matches(view)); + assertFalse(withTagValue(is((Object) "blah")).matches(view)); + } + + public void testWithTagKeyObject() { + View view = new View(getInstrumentation().getTargetContext()); + String testObjectText1 = "test text1!"; + String testObjectText2 = "test text2!"; + assertFalse(withTagKey(R.id.testId1, is((Object) testObjectText1)).matches(view)); + view.setTag(R.id.testId1, testObjectText1); + assertTrue(withTagKey(R.id.testId1, is((Object) testObjectText1)).matches(view)); + assertFalse(withTagKey(R.id.testId1, is((Object) testObjectText2)).matches(view)); + assertFalse(withTagKey(R.id.testId2, is((Object) testObjectText1)).matches(view)); + assertFalse(withTagKey(R.id.testId3, is((Object) testObjectText1)).matches(view)); + assertFalse(withTagKey(65535, is((Object) testObjectText1)).matches(view)); + assertFalse(withTagValue(is((Object) "blah")).matches(view)); + + view.setTag(R.id.testId2, testObjectText2); + assertTrue(withTagKey(R.id.testId1, is((Object) testObjectText1)).matches(view)); + assertFalse(withTagKey(R.id.testId1, is((Object) testObjectText2)).matches(view)); + assertTrue(withTagKey(R.id.testId2, is((Object) testObjectText2)).matches(view)); + assertFalse(withTagKey(R.id.testId2, is((Object) testObjectText1)).matches(view)); + assertFalse(withTagKey(R.id.testId3, is((Object) testObjectText1)).matches(view)); + assertFalse(withTagKey(65535, is((Object) testObjectText1)).matches(view)); + assertFalse(withTagValue(is((Object) "blah")).matches(view)); + } + + public void testWithTextNull() { + try { + withText((Matcher<String>) null); + fail("Should of thrown NPE"); + } catch (NullPointerException e) { + // Good, this is expected. + } + } + + public void testCheckBoxMatchers() { + assertFalse(isChecked().matches(new Spinner(getInstrumentation().getTargetContext()))); + assertFalse(isNotChecked().matches(new Spinner(getInstrumentation().getTargetContext()))); + + CheckBox checkBox = new CheckBox(getInstrumentation().getTargetContext()); + checkBox.setChecked(true); + assertTrue(isChecked().matches(checkBox)); + assertFalse(isNotChecked().matches(checkBox)); + + checkBox.setChecked(false); + assertFalse(isChecked().matches(checkBox)); + assertTrue(isNotChecked().matches(checkBox)); + + RadioButton radioButton = new RadioButton(getInstrumentation().getTargetContext()); + radioButton.setChecked(false); + assertFalse(isChecked().matches(radioButton)); + assertTrue(isNotChecked().matches(radioButton)); + + radioButton.setChecked(true); + assertTrue(isChecked().matches(radioButton)); + assertFalse(isNotChecked().matches(radioButton)); + + CheckedTextView checkedText = new CheckedTextView(getInstrumentation().getTargetContext()); + checkedText.setChecked(false); + assertFalse(isChecked().matches(checkedText)); + assertTrue(isNotChecked().matches(checkedText)); + + checkedText.setChecked(true); + assertTrue(isChecked().matches(checkedText)); + assertFalse(isNotChecked().matches(checkedText)); + + Checkable checkable = new Checkable() { + @Override + public boolean isChecked() { return true; } + @Override + public void setChecked(boolean ignored) {} + @Override + public void toggle() {} + }; + + assertFalse(isChecked().matches(checkable)); + assertFalse(isNotChecked().matches(checkable)); + } + + public void testWithTextString() { + TextView textView = new TextView(getInstrumentation().getTargetContext()); + textView.setText(null); + assertTrue(withText(is("")).matches(textView)); + String testText = "test text!"; + textView.setText(testText); + assertTrue(withText(is(testText)).matches(textView)); + assertFalse(withText(is("blah")).matches(textView)); + assertFalse(withText(is("")).matches(textView)); + } + + public void testHasDescendant() { + View v = new TextView(getInstrumentation().getTargetContext()); + ViewGroup parent = new RelativeLayout(getInstrumentation().getTargetContext()); + ViewGroup grany = new ScrollView(getInstrumentation().getTargetContext()); + grany.addView(parent); + parent.addView(v); + assertTrue(hasDescendant(isAssignableFrom(TextView.class)).matches(grany)); + assertTrue(hasDescendant(isAssignableFrom(TextView.class)).matches(parent)); + assertFalse(hasDescendant(isAssignableFrom(ScrollView.class)).matches(parent)); + assertFalse(hasDescendant(isAssignableFrom(TextView.class)).matches(v)); + } + + public void testIsDescendantOfA() { + View v = new TextView(getInstrumentation().getTargetContext()); + ViewGroup parent = new RelativeLayout(getInstrumentation().getTargetContext()); + ViewGroup grany = new ScrollView(getInstrumentation().getTargetContext()); + grany.addView(parent); + parent.addView(v); + assertTrue(isDescendantOfA(isAssignableFrom(RelativeLayout.class)).matches(v)); + assertTrue(isDescendantOfA(isAssignableFrom(ScrollView.class)).matches(v)); + assertFalse(isDescendantOfA(isAssignableFrom(LinearLayout.class)).matches(v)); + } + + public void testIsVisible() { + View visible = new View(getInstrumentation().getTargetContext()); + visible.setVisibility(View.VISIBLE); + View invisible = new View(getInstrumentation().getTargetContext()); + invisible.setVisibility(View.INVISIBLE); + assertTrue(withEffectiveVisibility(Visibility.VISIBLE).matches(visible)); + assertFalse(withEffectiveVisibility(Visibility.VISIBLE).matches(invisible)); + + // Make the visible view invisible by giving it an invisible parent. + ViewGroup parent = new RelativeLayout(getInstrumentation().getTargetContext()); + parent.addView(visible); + parent.setVisibility(View.INVISIBLE); + assertFalse(withEffectiveVisibility(Visibility.VISIBLE).matches(visible)); + } + + public void testIsInvisible() { + View visible = new View(getInstrumentation().getTargetContext()); + visible.setVisibility(View.VISIBLE); + View invisible = new View(getInstrumentation().getTargetContext()); + invisible.setVisibility(View.INVISIBLE); + assertFalse(withEffectiveVisibility(Visibility.INVISIBLE).matches(visible)); + assertTrue(withEffectiveVisibility(Visibility.INVISIBLE).matches(invisible)); + + // Make the visible view invisible by giving it an invisible parent. + ViewGroup parent = new RelativeLayout(getInstrumentation().getTargetContext()); + parent.addView(visible); + parent.setVisibility(View.INVISIBLE); + assertTrue(withEffectiveVisibility(Visibility.INVISIBLE).matches(visible)); + } + + public void testIsGone() { + View gone = new View(getInstrumentation().getTargetContext()); + gone.setVisibility(View.GONE); + View visible = new View(getInstrumentation().getTargetContext()); + visible.setVisibility(View.VISIBLE); + assertFalse(withEffectiveVisibility(Visibility.GONE).matches(visible)); + assertTrue(withEffectiveVisibility(Visibility.GONE).matches(gone)); + + // Make the gone view gone by giving it a gone parent. + ViewGroup parent = new RelativeLayout(getInstrumentation().getTargetContext()); + parent.addView(visible); + parent.setVisibility(View.GONE); + assertTrue(withEffectiveVisibility(Visibility.GONE).matches(visible)); + } + + public void testIsClickable() { + View clickable = new View(getInstrumentation().getTargetContext()); + clickable.setClickable(true); + View notClickable = new View(getInstrumentation().getTargetContext()); + notClickable.setClickable(false); + assertTrue(isClickable().matches(clickable)); + assertFalse(isClickable().matches(notClickable)); + } + + public void testIsEnabled() { + View enabled = new View(getInstrumentation().getTargetContext()); + enabled.setEnabled(true); + View notEnabled = new View(getInstrumentation().getTargetContext()); + notEnabled.setEnabled(false); + assertTrue(isEnabled().matches(enabled)); + assertFalse(isEnabled().matches(notEnabled)); + } + + public void testIsFocusable() { + View focusable = new View(getInstrumentation().getTargetContext()); + focusable.setFocusable(true); + View notFocusable = new View(getInstrumentation().getTargetContext()); + notFocusable.setFocusable(false); + assertTrue(isFocusable().matches(focusable)); + assertFalse(isFocusable().matches(notFocusable)); + } + + public void testWithTextResourceId() { + TextView textView = new TextView(getInstrumentation().getTargetContext()); + textView.setText(R.string.something); + assertTrue(withText(R.string.something).matches(textView)); + assertFalse(withText(R.string.other_string).matches(textView)); + } + + public void testWithParent() { + View view1 = new TextView(getInstrumentation().getTargetContext()); + View view2 = new TextView(getInstrumentation().getTargetContext()); + View view3 = new TextView(getInstrumentation().getTargetContext()); + ViewGroup tiptop = new RelativeLayout(getInstrumentation().getTargetContext()); + ViewGroup secondLevel = new RelativeLayout(getInstrumentation().getTargetContext()); + secondLevel.addView(view2); + secondLevel.addView(view3); + tiptop.addView(secondLevel); + tiptop.addView(view1); + assertTrue(withParent(is((View) tiptop)).matches(view1)); + assertTrue(withParent(is((View) tiptop)).matches(secondLevel)); + assertFalse(withParent(is((View) tiptop)).matches(view2)); + assertFalse(withParent(is((View) tiptop)).matches(view3)); + assertFalse(withParent(is((View) secondLevel)).matches(view1)); + + assertTrue(withParent(is((View) secondLevel)).matches(view2)); + assertTrue(withParent(is((View) secondLevel)).matches(view3)); + + assertFalse(withParent(is(view3)).matches(view3)); + } + + public void testWithChild() { + View view1 = new TextView(getInstrumentation().getTargetContext()); + View view2 = new TextView(getInstrumentation().getTargetContext()); + View view3 = new TextView(getInstrumentation().getTargetContext()); + ViewGroup tiptop = new RelativeLayout(getInstrumentation().getTargetContext()); + ViewGroup secondLevel = new RelativeLayout(getInstrumentation().getTargetContext()); + secondLevel.addView(view2); + secondLevel.addView(view3); + tiptop.addView(secondLevel); + tiptop.addView(view1); + assertTrue(withChild(is(view1)).matches(tiptop)); + assertTrue(withChild(is((View) secondLevel)).matches(tiptop)); + assertFalse(withChild(is((View) tiptop)).matches(view1)); + assertFalse(withChild(is(view2)).matches(tiptop)); + assertFalse(withChild(is(view1)).matches(secondLevel)); + + assertTrue(withChild(is(view2)).matches(secondLevel)); + + assertFalse(withChild(is(view3)).matches(view3)); + } + + public void testIsRootView() { + ViewGroup rootView = new ViewGroup(getInstrumentation().getTargetContext()) { + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + } + }; + + View view = new View(getInstrumentation().getTargetContext()); + rootView.addView(view); + + assertTrue(isRoot().matches(rootView)); + assertFalse(isRoot().matches(view)); + } + + public void testHasSibling() { + TextView v1 = new TextView(getInstrumentation().getTargetContext()); + v1.setText("Bill Odama"); + Button v2 = new Button(getInstrumentation().getTargetContext()); + View v3 = new View(getInstrumentation().getTargetContext()); + ViewGroup parent = new LinearLayout(getInstrumentation().getTargetContext()); + parent.addView(v1); + parent.addView(v2); + parent.addView(v3); + assertTrue(hasSibling(withText("Bill Odama")).matches(v2)); + assertFalse(hasSibling(is(v3)).matches(parent)); + } + + public void testHasImeAction() { + EditText editText = new EditText(getInstrumentation().getTargetContext()); + assertFalse(hasImeAction(EditorInfo.IME_ACTION_GO).matches(editText)); + editText.setImeOptions(EditorInfo.IME_ACTION_NEXT); + assertFalse(hasImeAction(EditorInfo.IME_ACTION_GO).matches(editText)); + assertTrue(hasImeAction(EditorInfo.IME_ACTION_NEXT).matches(editText)); + } + + public void testHasImeActionNoInputConnection() { + Button button = new Button(getInstrumentation().getTargetContext()); + assertFalse(hasImeAction(0).matches(button)); + } + + public void testSupportsInputMethods() { + Button button = new Button(getInstrumentation().getTargetContext()); + EditText editText = new EditText(getInstrumentation().getTargetContext()); + assertFalse(supportsInputMethods().matches(button)); + assertTrue(supportsInputMethods().matches(editText)); + } +} diff --git a/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/util/TreeIterablesTest.java b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/util/TreeIterablesTest.java new file mode 100644 index 0000000..9b2bdcc --- /dev/null +++ b/espresso/espresso-lib-tests/src/androidTest/java/com/google/android/apps/common/testing/ui/espresso/util/TreeIterablesTest.java @@ -0,0 +1,221 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.util; + +import static com.google.common.base.Preconditions.checkNotNull; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.hasEntry; +import static org.hamcrest.Matchers.is; + +import com.google.android.apps.common.testing.ui.espresso.util.TreeIterables.DistanceRecordingTreeViewer; +import com.google.android.apps.common.testing.ui.espresso.util.TreeIterables.TreeViewer; +import com.google.common.base.Function; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterables; +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import com.google.common.collect.Sets; + +import junit.framework.TestCase; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Unit tests for {@link TreeIterables}. */ +public class TreeIterablesTest extends TestCase { + + private static class TestElement { + private final String data; + private final ImmutableList<TestElement> children; + public TestElement(String data, TestElement ... children) { + this.data = checkNotNull(data); + this.children = ImmutableList.copyOf(children); + } + } + + private static class TestElementTreeViewer implements TreeViewer<TestElement> { + @Override + public Collection<TestElement> children(TestElement element) { + return element.children; + } + } + + private static class TestElementStringConvertor implements Function<TestElement, String> { + @Override + public String apply(TestElement e) { + return e.data; + } + } + + + private static final TestElement trivialTree = + new TestElement("a", new TestElement("b", new TestElement("c", new TestElement("d")))); + + private static final TestElement complexTree = + new TestElement("a", + new TestElement("b", + new TestElement("c", + new TestElement("d"), + new TestElement("e", + new TestElement("f"))), + new TestElement("g"), + new TestElement("h", + new TestElement("i", + new TestElement("j", + new TestElement("k"))))), + new TestElement("l"), + new TestElement("m"), + new TestElement("n", + new TestElement("o", + new TestElement("p"), + new TestElement("q")))); + + public void testDistanceRecorder_unknownItemThrowsException() { + final DistanceRecordingTreeViewer<TestElement> distanceRecorder = + new DistanceRecordingTreeViewer<TestElement>(complexTree, new TestElementTreeViewer()); + try { + distanceRecorder.getDistance(new TestElement("hello")); + fail("node should be unknown"); + } catch (RuntimeException expected) { } + } + + public void testDistanceRecorder_unprocessedChildThrowsException() { + final DistanceRecordingTreeViewer<TestElement> distanceRecorder = + new DistanceRecordingTreeViewer<TestElement>(complexTree, new TestElementTreeViewer()); + + try { + distanceRecorder.getDistance(complexTree.children.iterator().next()); + fail("distance recorder hasnt processed this child yet, cannot know distance"); + } catch (RuntimeException expected) { } + } + + public void testDistanceRecorder_distanceKnownAfterChildrenCall() { + final DistanceRecordingTreeViewer<TestElement> distanceRecorder = + new DistanceRecordingTreeViewer<TestElement>(complexTree, new TestElementTreeViewer()); + + @SuppressWarnings("unused") + List<TestElement> createdForSideEffect = Lists.newArrayList( + distanceRecorder.children(complexTree)); + + assertThat(distanceRecorder.getDistance(complexTree), is(0)); + assertThat(distanceRecorder.getDistance(complexTree.children.iterator().next()), is(1)); + } + + @SuppressWarnings("unchecked") + public void testComplexTree_Distances() { + final DistanceRecordingTreeViewer<TestElement> distanceRecorder = + new DistanceRecordingTreeViewer<TestElement>(complexTree, new TestElementTreeViewer()); + Iterable<TestElement> complexIterable = TreeIterables.depthFirstTraversal(complexTree, + distanceRecorder); + Set<TestElement> complexSet = Sets.newHashSet(complexIterable); + Map<String, Integer> distancesByData = Maps.newHashMap(); + for (TestElement e : complexSet) { + distancesByData.put(e.data, distanceRecorder.getDistance(e)); + } + + assertThat(distancesByData, allOf( + hasEntry("a", 0), + hasEntry("b", 1), + hasEntry("c", 2), + hasEntry("d", 3), + hasEntry("e", 3), + hasEntry("f", 4), + hasEntry("g", 2), + hasEntry("h", 2), + hasEntry("i", 3), + hasEntry("j", 4), + hasEntry("k", 5), + hasEntry("l", 1), + hasEntry("m", 1), + hasEntry("n", 1), + hasEntry("o", 2), + hasEntry("p", 3), + hasEntry("q", 3))); + assertThat(distancesByData.size(), is(17)); + + List<String> traversalOrder = Lists.newArrayList(Iterables.transform( + complexIterable, + new TestElementStringConvertor())); + + // should be depth first if forwarding correctly. + assertThat(traversalOrder, + is((List<String>) Lists.newArrayList( + "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q"))); + } + + public void testComplexTraversal_depthFirst() { + List<String> breadthFirst = Lists.newArrayList(Iterables.transform( + TreeIterables.depthFirstTraversal(complexTree, new TestElementTreeViewer()), + new TestElementStringConvertor())); + assertThat(breadthFirst, + is((Iterable<String>) Lists.newArrayList( + "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q"))); + } + + public void testComplexTraversal_breadthFirst() { + List<String> breadthFirst = Lists.newArrayList(Iterables.transform( + TreeIterables.breadthFirstTraversal(complexTree, new TestElementTreeViewer()), + new TestElementStringConvertor())); + assertThat(breadthFirst, + is((List<String>) Lists.newArrayList( + "a", //root + "b", "l", "m", "n", //L1 + "c", "g", "h", "o", //L2 + "d", "e", "i", "p", "q", //L3 + "f", "j", // L4 + "k"))); //L5 + } + + public void testTrivialTraversal_breadthFirst() { + // essentially the same as depth first. + List<String> breadthFirst = Lists.newArrayList(Iterables.transform( + TreeIterables.breadthFirstTraversal(trivialTree, new TestElementTreeViewer()), + new TestElementStringConvertor())); + assertThat(breadthFirst, is((List<String>) Lists.newArrayList("a", "b", "c", "d"))); + } + + public void testTrivialTraversal_depthFirst() { + List<String> depthFirst = Lists.newArrayList(Iterables.transform( + TreeIterables.depthFirstTraversal(trivialTree, new TestElementTreeViewer()), + new TestElementStringConvertor())); + assertThat(depthFirst, is((List<String>) Lists.newArrayList("a", "b", "c", "d"))); + } + + @SuppressWarnings("unchecked") + public void testTrivial_distance() { + final DistanceRecordingTreeViewer<TestElement> distanceRecorder = + new DistanceRecordingTreeViewer<TestElement>(trivialTree, new TestElementTreeViewer()); + + Iterable<TestElement> trivialIterable = TreeIterables.depthFirstTraversal(trivialTree, + distanceRecorder); + Set<TestElement> trivialSet = Sets.newHashSet(trivialIterable); + Map<String, Integer> distancesByData = Maps.newHashMap(); + for (TestElement e : trivialSet) { + distancesByData.put(e.data, distanceRecorder.getDistance(e)); + } + + assertThat(distancesByData, allOf( + hasEntry("a", 0), + hasEntry("b", 1), + hasEntry("c", 2), + hasEntry("d", 3))); + assertThat(distancesByData.size(), is(4)); + } +} diff --git a/espresso/espresso-lib-tests/src/androidTest/res/values/id.xml b/espresso/espresso-lib-tests/src/androidTest/res/values/id.xml new file mode 100644 index 0000000..62358e3 --- /dev/null +++ b/espresso/espresso-lib-tests/src/androidTest/res/values/id.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2014 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<resources> + <!-- IDs used for testing purposes --> + <item type="id" name="testId1" /> + <item type="id" name="testId2" /> + <item type="id" name="testId3" /> +</resources> diff --git a/espresso/espresso-lib-tests/src/androidTest/res/values/strings.xml b/espresso/espresso-lib-tests/src/androidTest/res/values/strings.xml new file mode 100644 index 0000000..54a4ecc --- /dev/null +++ b/espresso/espresso-lib-tests/src/androidTest/res/values/strings.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="utf-8"?> + +<!-- + ~ Copyright (C) 2014 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<resources> + <string name="something">Hello World</string> + <string name="other_string">Goodbye!!</string> +</resources> diff --git a/espresso/espresso-lib/build.gradle b/espresso/espresso-lib/build.gradle new file mode 100644 index 0000000..b02908f --- /dev/null +++ b/espresso/espresso-lib/build.gradle @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +apply plugin: 'android-library' + +sourceCompatibility = JavaVersion.VERSION_1_5 +targetCompatibility = JavaVersion.VERSION_1_5 + +repositories { + maven { url '../../../../prebuilts/tools/common/m2/repository' } + maven { url '../../../../prebuilts/tools/common/m2/internal' } +} + +android { + compileSdkVersion 19 + buildToolsVersion '19.0.3' + + // to overwrite debug variant + publishNonDefault true + + lintOptions { + abortOnError false + } + + packagingOptions { + exclude 'LICENSE.txt' + } +} + +// create separate scope for jarjar +configurations { + jarjar +} + +dependencies { + // set to provided since we're manually adding the JarJar'd version + provided files('../libs/dagger-1.2.1.jar') + provided files('../libs/dagger-compiler-1.2.1.jar') + provided files('../libs/guava-14.0.1.jar') + + compile project(':idling-resource-interface') + compile 'javax.annotation:javax.annotation-api:1.2' + compile 'javax.inject:javax.inject:1' + compile 'com.google.code.findbugs:jsr305:2.0.1' + compile 'org.hamcrest:hamcrest-library:1.1' + compile 'org.hamcrest:hamcrest-integration:1.1' + compile 'org.hamcrest:hamcrest-core:1.1' + compile 'com.squareup:javawriter:2.1.1' + + jarjar files('../libs/jarjar-1.4.jar') + + // Temporarily include the Google3 TestRunner as a static jar + // until it's merged with the Android one. + compile files('../libs/testrunner-runtime-1.1.jar') + compile files('../libs/testrunner-1.1.jar') +} + +android.libraryVariants.all { variant -> + + // To run unit tests against un-jarjar version of the lib. + if (variant.buildType.name.equals(com.android.builder.BuilderConstants.DEBUG)) { + println "Skipping debug build type." + return; + } + + def classesJar = "$project.buildDir/bundles/$variant.dirName/classes.jar" + def tmpClassesJarDir = "$project.buildDir/pre-jarjar/$variant.dirName" + def tmpClassesJar = "$tmpClassesJarDir/classes.jar" + + def depDaggerJar = "../libs/dagger-1.2.1.jar" + def depGuavaJar = "../libs/guava-14.0.1.jar" + def jarJarTaskName = "jarJar${variant.name.capitalize()}" + + task "$jarJarTaskName" << { + project.ant { + taskdef name: "jarjar", classname: "com.tonicsystems.jarjar.JarJarTask", + classpath: configurations.jarjar.asPath + jarjar(jarfile: "$classesJar", filesetmanifest: "merge") { + zipfileset(src: "$depGuavaJar") + zipfileset(src: "$depDaggerJar") + zipfileset(src: "$tmpClassesJar") + rule pattern: "com.google.common.**", + result: "com.google.android.apps.common.testing.deps.guava.@1" + rule pattern: "dagger.**", + result: "com.google.android.apps.common.testing.deps.dagger.@1" + } + } + } + + // get access to the normal jar class. Change its output to somewhere else, and make jarjar depend on it. + Jar classesJarTask = (Jar) project.tasks.getByName("package${variant.name.capitalize()}Jar") + + classesJarTask.destinationDir = project.file("$tmpClassesJarDir") + project.tasks.getByName("$jarJarTaskName").dependsOn classesJarTask, configurations.provided + + variant.packageLibrary.dependsOn "$jarJarTaskName" +} + +apply from: '../publishLocal.gradle' diff --git a/espresso/espresso-lib/gradle.properties b/espresso/espresso-lib/gradle.properties new file mode 100644 index 0000000..df3f7c9 --- /dev/null +++ b/espresso/espresso-lib/gradle.properties @@ -0,0 +1,21 @@ +# +# Copyright (C) 2014 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +VERSION=1.2 +POM_NAME=Espresso Library +GROUP_ID=com.google.android.apps.common.testing +POM_ARTIFACT_ID=espresso-lib +POM_PACKAGING=aar
\ No newline at end of file diff --git a/espresso/espresso-lib/src/main/AndroidManifest.xml b/espresso/espresso-lib/src/main/AndroidManifest.xml new file mode 100644 index 0000000..e6813a3 --- /dev/null +++ b/espresso/espresso-lib/src/main/AndroidManifest.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2014 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + package="com.google.android.apps.common.testing.ui.espresso" > + + <uses-sdk + android:minSdkVersion="7"/> + + <application /> + +</manifest>
\ No newline at end of file diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/AmbiguousViewMatcherException.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/AmbiguousViewMatcherException.java new file mode 100644 index 0000000..41c3678 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/AmbiguousViewMatcherException.java @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.android.apps.common.testing.ui.espresso.util.HumanReadables; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Lists; + +import android.view.View; + +import org.hamcrest.Matcher; + +/** + * An exception which indicates that a Matcher<View> matched multiple views in the hierarchy when + * only one view was expected. It should be called only from the main thread. + * <p> + * Contains details about the matcher and the current view hierarchy to aid in debugging. + * </p> + * <p> + * Since this is usually an unrecoverable error this exception is a runtime exception. + * </p> + * <p> + * References to the view and failing matcher are purposefully not included in the state of this + * object - since it will most likely be created on the UI thread and thrown on the instrumentation + * thread, it would be invalid to touch the view on the instrumentation thread. Also the view + * hierarchy may have changed since exception creation (leading to more confusion). + * </p> + */ +public final class AmbiguousViewMatcherException extends RuntimeException + implements EspressoException { + + private Matcher<? super View> viewMatcher; + private View rootView; + private View view1; + private View view2; + private View[] others; + + private AmbiguousViewMatcherException(String description) { + super(description); + } + + private AmbiguousViewMatcherException(Builder builder) { + super(getErrorMessage(builder)); + this.viewMatcher = builder.viewMatcher; + this.rootView = builder.rootView; + this.view1 = builder.view1; + this.view2 = builder.view2; + this.others = builder.others; + } + + private static String getErrorMessage(Builder builder) { + String errorMessage = ""; + if (builder.includeViewHierarchy) { + ImmutableSet<View> ambiguousViews = + ImmutableSet.<View>builder().add(builder.view1, builder.view2).add(builder.others).build(); + errorMessage = HumanReadables.getViewHierarchyErrorMessage(builder.rootView, + Lists.newArrayList(ambiguousViews), + String.format("'%s' matches multiple views in the hierarchy.", builder.viewMatcher), + "****MATCHES****"); + } else { + errorMessage = String.format("Multiple Ambiguous Views found for matcher %s", + builder.viewMatcher); + } + return errorMessage; + } + + /** Builder for {@link AmbiguousViewMatcherException}. */ + public static class Builder { + private Matcher<? super View> viewMatcher; + private View rootView; + private View view1; + private View view2; + private View[] others; + private boolean includeViewHierarchy = true; + + public Builder from(AmbiguousViewMatcherException exception) { + this.viewMatcher = exception.viewMatcher; + this.rootView = exception.rootView; + this.view1 = exception.view1; + this.view2 = exception.view2; + this.others = exception.others; + return this; + } + + public Builder withViewMatcher(Matcher<? super View> viewMatcher) { + this.viewMatcher = viewMatcher; + return this; + } + + public Builder withRootView(View rootView) { + this.rootView = rootView; + return this; + } + + public Builder withView1(View view1) { + this.view1 = view1; + return this; + } + + public Builder withView2(View view2) { + this.view2 = view2; + return this; + } + + public Builder withOtherAmbiguousViews(View... others) { + this.others = others; + return this; + } + + public Builder includeViewHierarchy(boolean includeViewHierarchy) { + this.includeViewHierarchy = includeViewHierarchy; + return this; + } + + public AmbiguousViewMatcherException build() { + checkNotNull(viewMatcher); + checkNotNull(rootView); + checkNotNull(view1); + checkNotNull(view2); + checkNotNull(others); + return new AmbiguousViewMatcherException(this); + } + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/AndroidManifest.xml b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/AndroidManifest.xml new file mode 100644 index 0000000..e32c392 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/AndroidManifest.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2014 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + package="com.google.android.apps.common.testing.ui.espresso" > + + <uses-sdk + android:minSdkVersion="7" + android:targetSdkVersion="17" /> + + <application android:label="Espresso" /> + +</manifest>
\ No newline at end of file diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/AppNotIdleException.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/AppNotIdleException.java new file mode 100644 index 0000000..e84fcca --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/AppNotIdleException.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso; + +import static com.google.common.base.Preconditions.checkState; + +import com.google.common.base.Joiner; + +import android.os.Looper; + +import java.util.List; + +/** + * An exception which indicates that the App has not become idle even after the specified duration. + */ +public final class AppNotIdleException extends RuntimeException implements EspressoException { + + private AppNotIdleException(String description) { + super(description); + } + + /** + * Creates a new AppNotIdleException suitable for erroring out a test case. + * + * This should be called only from the main thread if the app does not idle out within the + * specified duration. + * + * @param idleConditions list of idleConditions that failed to become idle. + * @param loopCount number of times it was tried to check if they became idle. + * @param seconds number of seconds that was tried before giving up. + * + * @return a AppNotIdleException suitable to be thrown on the instrumentation thread. + */ + @Deprecated + public static AppNotIdleException create(List<String> idleConditions, int loopCount, + int seconds) { + checkState(Looper.myLooper() == Looper.getMainLooper()); + String errorMessage = String.format("App not idle within timeout of %s seconds even" + + "after trying for %s iterations. The following Idle Conditions failed %s", + seconds, loopCount, Joiner.on(",").join(idleConditions)); + return new AppNotIdleException(errorMessage); + } + + /** + * Creates a new AppNotIdleException suitable for erroring out a test case. + * + * This should be called only from the main thread if the app does not idle out within the + * specified duration. + * + * @param idleConditions list of idleConditions that failed to become idle. + * @param message a message about the failure. + * + * @return a AppNotIdleException suitable to be thrown on the instrumentation thread. + */ + static AppNotIdleException create(List<String> idleConditions, String message) { + String errorMessage = String.format("%s The following Idle Conditions failed %s.", + message, Joiner.on(",").join(idleConditions)); + return new AppNotIdleException(errorMessage); + } +} + diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/DataInteraction.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/DataInteraction.java new file mode 100644 index 0000000..aa13d5e --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/DataInteraction.java @@ -0,0 +1,207 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso; + + +import static com.google.android.apps.common.testing.ui.espresso.Espresso.onView; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isAssignableFrom; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDescendantOfA; +import static com.google.common.base.Preconditions.checkNotNull; +import static org.hamcrest.Matchers.allOf; + +import com.google.android.apps.common.testing.ui.espresso.action.AdapterDataLoaderAction; +import com.google.android.apps.common.testing.ui.espresso.action.AdapterViewProtocol; +import com.google.android.apps.common.testing.ui.espresso.action.AdapterViewProtocol.AdaptedData; +import com.google.android.apps.common.testing.ui.espresso.action.AdapterViewProtocols; +import com.google.android.apps.common.testing.ui.espresso.matcher.RootMatchers; +import com.google.common.base.Optional; + +import android.view.View; +import android.view.ViewParent; +import android.widget.Adapter; +import android.widget.AdapterView; + +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeMatcher; + +/** + * An interface to interact with data displayed in AdapterViews. + * <p> + * This interface builds on top of {@link ViewInteraction} and should be the preferred way to + * interact with elements displayed inside AdapterViews. + * </p> + * <p> + * This is necessary because an AdapterView may not load all the data held by its Adapter into the + * view hierarchy until a user interaction makes it necessary. Also it is more fluent / less brittle + * to match upon the data object being rendered into the display then the rendering itself. + * </p> + * <p> + * By default, a DataInteraction takes place against any AdapterView found within the current + * screen, if you have multiple AdapterView objects displayed, you will need to narrow the selection + * by using the inAdapterView method. + * </p> + * <p> + * The check and perform method operate on the top level child of the adapter view, if you need to + * operate on a subview (eg: a Button within the list) use the onChildView method before calling + * perform or check. + * </p> + * + */ +public class DataInteraction { + + private final Matcher<Object> dataMatcher; + private Matcher<View> adapterMatcher = isAssignableFrom(AdapterView.class); + private Optional<Matcher<View>> childViewMatcher = Optional.absent(); + private Optional<Integer> atPosition = Optional.absent(); + private AdapterViewProtocol adapterViewProtocol = AdapterViewProtocols.standardProtocol(); + private Matcher<Root> rootMatcher = RootMatchers.DEFAULT; + + DataInteraction(Matcher<Object> dataMatcher) { + this.dataMatcher = checkNotNull(dataMatcher); + } + + /** + * Causes perform and check methods to take place on a specific child view of the view returned + * by Adapter.getView() + */ + public DataInteraction onChildView(Matcher<View> childMatcher) { + this.childViewMatcher = Optional.of(checkNotNull(childMatcher)); + return this; + } + + /** + * Causes this data interaction to work within the Root specified by the given root matcher. + */ + public DataInteraction inRoot(Matcher<Root> rootMatcher) { + this.rootMatcher = checkNotNull(rootMatcher); + return this; + } + + /** + * Selects a particular adapter view to operate on, by default we operate on any adapter view + * on the screen. + */ + public DataInteraction inAdapterView(Matcher<View> adapterMatcher) { + this.adapterMatcher = checkNotNull(adapterMatcher); + return this; + } + + /** + * Selects the view which matches the nth position on the adapter + * based on the data matcher. + */ + public DataInteraction atPosition(Integer atPosition) { + this.atPosition = Optional.of(checkNotNull(atPosition)); + return this; + } + + /** + * Use a different AdapterViewProtocol if the Adapter implementation does not + * satisfy the AdapterView contract like (@code ExpandableListView) + */ + public DataInteraction usingAdapterViewProtocol(AdapterViewProtocol adapterViewProtocol) { + this.adapterViewProtocol = checkNotNull(adapterViewProtocol); + return this; + } + + /** + * Performs an action on the view after we force the data to be loaded. + * + * @return an {@link ViewInteraction} for more assertions or actions. + */ + public ViewInteraction perform(ViewAction... actions) { + AdapterDataLoaderAction adapterDataLoaderAction = load(); + + return onView(makeTargetMatcher(adapterDataLoaderAction)) + .inRoot(rootMatcher) + .perform(actions); + } + + /** + * Performs an assertion on the state of the view after we force the data to be loaded. + * + * @return an {@link ViewInteraction} for more assertions or actions. + */ + public ViewInteraction check(ViewAssertion assertion) { + AdapterDataLoaderAction adapterDataLoaderAction = load(); + + return onView(makeTargetMatcher(adapterDataLoaderAction)) + .inRoot(rootMatcher) + .check(assertion); + } + + private AdapterDataLoaderAction load() { + AdapterDataLoaderAction adapterDataLoaderAction = + new AdapterDataLoaderAction(dataMatcher, atPosition, adapterViewProtocol); + onView(adapterMatcher) + .inRoot(rootMatcher) + .perform(adapterDataLoaderAction); + return adapterDataLoaderAction; + } + + @SuppressWarnings("unchecked") + private Matcher<View> makeTargetMatcher(AdapterDataLoaderAction adapterDataLoaderAction) { + Matcher<View> targetView = displayingData(adapterMatcher, dataMatcher, adapterViewProtocol, + adapterDataLoaderAction); + if (childViewMatcher.isPresent()) { + targetView = allOf(childViewMatcher.get(), isDescendantOfA(targetView)); + } + return targetView; + } + + private Matcher<View> displayingData( + final Matcher<View> adapterMatcher, + final Matcher<Object> dataMatcher, + final AdapterViewProtocol adapterViewProtocol, + final AdapterDataLoaderAction adapterDataLoaderAction) { + checkNotNull(adapterMatcher); + checkNotNull(dataMatcher); + checkNotNull(adapterViewProtocol); + + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText(" displaying data matching: "); + dataMatcher.describeTo(description); + description.appendText(" within adapter view matching: "); + adapterMatcher.describeTo(description); + } + + @SuppressWarnings("unchecked") + @Override + public boolean matchesSafely(View view) { + + ViewParent parent = view.getParent(); + + while (parent != null && !(parent instanceof AdapterView)) { + parent = parent.getParent(); + } + + if (parent != null && adapterMatcher.matches(parent)) { + Optional<AdaptedData> data = adapterViewProtocol.getDataRenderedByView( + (AdapterView<? extends Adapter>) parent, view); + if (data.isPresent()) { + return adapterDataLoaderAction.getAdaptedData().opaqueToken.equals( + data.get().opaqueToken); + } + } + return false; + } + }; + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/Espresso.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/Espresso.java new file mode 100644 index 0000000..5e3d5f4 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/Espresso.java @@ -0,0 +1,255 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso; + +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.click; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.pressMenuKey; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDisplayed; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isRoot; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withClassName; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withContentDescription; +import static com.google.common.base.Preconditions.checkNotNull; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.anyOf; +import static org.hamcrest.Matchers.endsWith; + +import com.google.android.apps.common.testing.ui.espresso.action.ViewActions; +import com.google.android.apps.common.testing.ui.espresso.base.BaseLayerModule; +import com.google.android.apps.common.testing.ui.espresso.base.IdlingResourceRegistry; +import com.google.android.apps.common.testing.ui.espresso.util.TreeIterables; + +import android.content.Context; +import android.os.Build; +import android.os.Looper; +import android.view.View; +import android.view.ViewConfiguration; + +import dagger.ObjectGraph; + +import org.hamcrest.Matcher; + +/** + * Entry point to the Espresso framework. Test authors can initiate testing by using one of the on* + * methods (e.g. onView) or perform top-level user actions (e.g. pressBack). + */ +public final class Espresso { + + static ObjectGraph espressoGraph() { + return GraphHolder.graph(); + } + + private Espresso() {} + + /** + * Creates an {@link PartiallyScopedViewInteraction} for a given view. Note: the view has + * to be part of the view hierarchy. This may not be the case if it is rendered as part of + * an AdapterView (e.g. ListView). If this is the case, use Espresso.onData to load the view + * first. + * + * @param viewMatcher used to select the view. + * @see #onData + */ + public static ViewInteraction onView(final Matcher<View> viewMatcher) { + return espressoGraph().plus(new ViewInteractionModule(viewMatcher)).get(ViewInteraction.class); + } + + + + /** + * Creates an {@link DataInteraction} for a data object displayed by the application. Use this + * method to load (into the view hierarchy) items from AdapterView widgets (e.g. ListView). + * + * @param dataMatcher a matcher used to find the data object. + */ + public static DataInteraction onData(Matcher<Object> dataMatcher) { + return new DataInteraction(dataMatcher); + } + + /** + * Registers a Looper for idle checking with the framework. This is intended for use with + * non-UI thread loopers. + * + * @throws IllegalArgumentException if looper is the main looper. + */ + public static void registerLooperAsIdlingResource(Looper looper) { + registerLooperAsIdlingResource(looper, false); + } + + /** + * Registers a Looper for idle checking with the framework. This is intended for use with + * non-UI thread loopers. + * + * This method allows the caller to consider Thread.State.WAIT to be 'idle'. + * + * This is useful in the case where a looper is sending a message to the UI thread synchronously + * through a wait/notify mechanism. + * + * @throws IllegalArgumentException if looper is the main looper. + */ + public static void registerLooperAsIdlingResource(Looper looper, boolean considerWaitIdle) { + espressoGraph().get(IdlingResourceRegistry.class).registerLooper(looper, considerWaitIdle); + } + + /** + * Registers one or more {@link IdlingResource}s with the framework. It is expected, although not + * strictly required, that this method will be called at test setup time prior to any interaction + * with the application under test. When registering more than one resource, ensure that each has + * a unique name. + */ + public static void registerIdlingResources(IdlingResource... resources) { + checkNotNull(resources); + IdlingResourceRegistry registry = espressoGraph().get(IdlingResourceRegistry.class); + for (IdlingResource resource : resources) { + checkNotNull(resource.getName(), "IdlingResource.getName() should not be null"); + registry.register(resource); + } + } + + /** + * Changes the default {@link FailureHandler} to the given one. + */ + public static void setFailureHandler(FailureHandler failureHandler) { + espressoGraph().get(BaseLayerModule.FailureHandlerHolder.class) + .update(checkNotNull(failureHandler)); + } + + /********************************** Top Level Actions ******************************************/ + + // Ideally, this should be only allOf(isDisplayed(), withContentDescription("More options")) + // But the ActionBarActivity compat lib is missing a content description for this element, so + // we add the class name matcher as another option to find the view. + @SuppressWarnings("unchecked") + private static final Matcher<View> OVERFLOW_BUTTON_MATCHER = anyOf( + allOf(isDisplayed(), withContentDescription("More options")), + allOf(isDisplayed(), withClassName(endsWith("OverflowMenuButton")))); + + + /** + * Closes soft keyboard if open. + */ + public static void closeSoftKeyboard() { + onView(isRoot()).perform(ViewActions.closeSoftKeyboard()); + } + + /** + * Opens the overflow menu displayed in the contextual options of an ActionMode. + * + * This works with both native and SherlockActionBar action modes. + * + * Note the significant difference in UX between ActionMode and ActionBar overflows - ActionMode + * will always present an overflow icon and that icon only responds to clicks. The menu button + * (if present) has no impact on it. + */ + @SuppressWarnings("unchecked") + public static void openContextualActionModeOverflowMenu() { + onView(isRoot()) + .perform(new TransitionBridgingViewAction()); + + onView(OVERFLOW_BUTTON_MATCHER) + .perform(click()); + } + + /** + * Press on the back button. + * + * @throws PerformException if currently displayed activity is root activity, since pressing back + * button would result in application closing. + */ + public static void pressBack() { + onView(isRoot()).perform(ViewActions.pressBack()); + } + + /** + * Opens the overflow menu displayed within an ActionBar. + * + * This works with both native and SherlockActionBar ActionBars. + * + * Note the significant differences of UX between ActionMode and ActionBars with respect to + * overflows. If a hardware menu key is present, the overflow icon is never displayed in + * ActionBars and can only be interacted with via menu key presses. + */ + @SuppressWarnings("unchecked") + public static void openActionBarOverflowOrOptionsMenu(Context context) { + if (context.getApplicationInfo().targetSdkVersion < Build.VERSION_CODES.HONEYCOMB) { + // regardless of the os level of the device, this app will be rendering a menukey + // in the virtual navigation bar (if present) or responding to hardware option keys on + // any activity. + onView(isRoot()) + .perform(pressMenuKey()); + } else if (hasVirtualOverflowButton(context)) { + // If we're using virtual keys - theres a chance we're in mid animation of switching + // between a contextual action bar and the non-contextual action bar. In this case there + // are 2 'More Options' buttons present. Lets wait till that is no longer the case. + onView(isRoot()) + .perform(new TransitionBridgingViewAction()); + + onView(OVERFLOW_BUTTON_MATCHER) + .perform(click()); + } else { + // either a hardware button exists, or we're on a pre-HC os. + onView(isRoot()) + .perform(pressMenuKey()); + } + } + + private static boolean hasVirtualOverflowButton(Context context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.ICE_CREAM_SANDWICH) { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB; + } else { + return !ViewConfiguration.get(context).hasPermanentMenuKey(); + } + } + + /** + * Handles the cases where the app is transitioning between a contextual action bar and a + * non contextual action bar. + */ + private static class TransitionBridgingViewAction implements ViewAction { + @Override + public void perform(UiController controller, View view) { + int loops = 0; + while (isTransitioningBetweenActionBars(view) && loops < 100) { + loops++; + controller.loopMainThreadForAtLeast(50); + } + // if we're not transitioning properly the next viewaction + // will give a decent enough exception. + } + + @Override + public String getDescription() { + return "Handle transition between action bar and action bar context."; + } + + @Override + public Matcher<View> getConstraints() { + return isRoot(); + } + + private boolean isTransitioningBetweenActionBars(View view) { + int actionButtonCount = 0; + for (View child : TreeIterables.breadthFirstViewTraversal(view)) { + if (OVERFLOW_BUTTON_MATCHER.matches(child)) { + actionButtonCount++; + } + } + return actionButtonCount > 1; + } + } + + +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/EspressoException.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/EspressoException.java new file mode 100644 index 0000000..4c6a5a2 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/EspressoException.java @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso; + +/** + * Used for identifying an exception as coming from the {@link Espresso} framework. + */ +public interface EspressoException {} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/FailureHandler.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/FailureHandler.java new file mode 100644 index 0000000..e0fb9c0 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/FailureHandler.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso; + +import android.view.View; + +import org.hamcrest.Matcher; + + + +/** + * Handles failures that happen during test execution. + */ +public interface FailureHandler { + + /** + * Handle the given error in a manner that makes sense to the environment in which the test is + * executed (e.g. take a screenshot, output extra debug info, etc). Upon handling, most handlers + * will choose to propagate the error. + */ + public void handle(Throwable error, Matcher<View> viewMatcher); + +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/GraphHolder.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/GraphHolder.java new file mode 100644 index 0000000..3ee8e55 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/GraphHolder.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso; + +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; + +import com.google.android.apps.common.testing.testrunner.UsageTrackerRegistry; +import com.google.android.apps.common.testing.ui.espresso.base.BaseLayerModule; +import com.google.android.apps.common.testing.ui.espresso.base.IdlingResourceRegistry; + +import dagger.Module; +import dagger.ObjectGraph; + +import java.util.concurrent.atomic.AtomicReference; + +/** + * Holds Espresso's ObjectGraph. + */ +public final class GraphHolder { + + private static final AtomicReference<GraphHolder> instance = + new AtomicReference<GraphHolder>(null); + + private final ObjectGraph graph; + + private GraphHolder(ObjectGraph graph) { + this.graph = checkNotNull(graph); + } + + static ObjectGraph graph() { + GraphHolder instanceRef = instance.get(); + if (null == instanceRef) { + instanceRef = new GraphHolder(ObjectGraph.create(EspressoModule.class)); + if (instance.compareAndSet(null, instanceRef)) { + UsageTrackerRegistry.getInstance().trackUsage("Espresso"); + return instanceRef.graph; + } else { + return instance.get().graph; + } + } else { + return instanceRef.graph; + } + } + + // moe:begin_strip + public static void initialize(Object... modules) { + checkNotNull(modules); + Object[] allModules = new Object[modules.length + 1]; + allModules[0] = EspressoModule.class; + System.arraycopy(modules, 0, allModules, 1, modules.length); + GraphHolder holder = new GraphHolder(ObjectGraph.create(modules)); + checkState(instance.compareAndSet(null, holder), "Espresso already initialized."); + } + // moe:end_strip + + @Module( + includes = BaseLayerModule.class, + injects = IdlingResourceRegistry.class + ) + static class EspressoModule { + } + +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/IdlingPolicies.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/IdlingPolicies.java new file mode 100644 index 0000000..17fdc8d --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/IdlingPolicies.java @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import java.util.concurrent.TimeUnit; + +/** + * Allows users fine grain control over idling policies. + * + * Espresso's default idling policies are suitable for most usecases - however + * certain execution environments (like the ARM emulator) might be very slow. + * This class allows users the ability to adjust defaults to sensible values + * for their environments. + */ +public final class IdlingPolicies { + + private IdlingPolicies() { } + + private static volatile IdlingPolicy masterIdlingPolicy = new IdlingPolicy.Builder() + .withIdlingTimeout(60) + .withIdlingTimeoutUnit(TimeUnit.SECONDS) + .throwAppNotIdleException() + .build(); + + + private static volatile IdlingPolicy dynamicIdlingResourceErrorPolicy = new IdlingPolicy.Builder() + .withIdlingTimeout(26) + .withIdlingTimeoutUnit(TimeUnit.SECONDS) + .throwIdlingResourceTimeoutException() + .build(); + + private static volatile IdlingPolicy dynamicIdlingResourceWarningPolicy = + new IdlingPolicy.Builder() + .withIdlingTimeout(5) + .withIdlingTimeoutUnit(TimeUnit.SECONDS) + .logWarning() + .build(); + + + /** + * Updates the IdlingPolicy used in UiController.loopUntil to detect AppNotIdleExceptions. + * + * @param timeout the timeout before an AppNotIdleException is created. + * @param unit the unit of the timeout value. + */ + public static void setMasterPolicyTimeout(long timeout, TimeUnit unit) { + checkArgument(timeout > 0); + checkNotNull(unit); + masterIdlingPolicy = masterIdlingPolicy.toBuilder() + .withIdlingTimeout(timeout) + .withIdlingTimeoutUnit(unit) + .build(); + } + + /** + * Updates the IdlingPolicy used by IdlingResourceRegistry to determine when IdlingResources + * timeout. + * + * @param timeout the timeout before an IdlingResourceTimeoutException is created. + * @param unit the unit of the timeout value. + */ + public static void setIdlingResourceTimeout(long timeout, TimeUnit unit) { + checkArgument(timeout > 0); + checkNotNull(unit); + dynamicIdlingResourceErrorPolicy = dynamicIdlingResourceErrorPolicy.toBuilder() + .withIdlingTimeout(timeout) + .withIdlingTimeoutUnit(unit) + .build(); + } + + + public static IdlingPolicy getMasterIdlingPolicy() { + return masterIdlingPolicy; + } + + public static IdlingPolicy getDynamicIdlingResourceWarningPolicy() { + return dynamicIdlingResourceWarningPolicy; + } + + public static IdlingPolicy getDynamicIdlingResourceErrorPolicy() { + return dynamicIdlingResourceErrorPolicy; + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/IdlingPolicy.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/IdlingPolicy.java new file mode 100644 index 0000000..533afdd --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/IdlingPolicy.java @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import android.util.Log; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Allows users to control idling idleTimeouts in Espresso. + */ +public final class IdlingPolicy { + private static final String TAG = "IdlingPolicy"; + private enum ResponseAction { THROW_APP_NOT_IDLE, THROW_IDLE_TIMEOUT, LOG_ERROR }; + + private final long idleTimeout; + private final TimeUnit unit; + private final ResponseAction errorHandler; + + /** + * The amount of time the policy allows a resource to be non-idle. + */ + public long getIdleTimeout(){ + return idleTimeout; + } + + /** + * The unit for {@linkgetIdleTimeout}. + */ + public TimeUnit getIdleTimeoutUnit() { + return unit; + } + + /** + * Invoked when the idle idleTimeout has been exceeded. + * + * @param busyResources the resources that are not idle. + * @param message an additional message to include in an exception. + */ + public void handleTimeout(List<String> busyResources, String message) { + switch (errorHandler) { + case THROW_APP_NOT_IDLE: + throw AppNotIdleException.create(busyResources, message); + case THROW_IDLE_TIMEOUT: + throw new IdlingResourceTimeoutException(busyResources); + case LOG_ERROR: + Log.w(TAG, "These resources are not idle: " + busyResources); + break; + default: + throw new IllegalStateException("should never reach here." + busyResources); + } + } + + Builder toBuilder() { + return new Builder(this); + } + + private IdlingPolicy(Builder builder) { + checkArgument(builder.idleTimeout > 0); + this.idleTimeout = builder.idleTimeout; + this.unit = checkNotNull(builder.unit); + this.errorHandler = checkNotNull(builder.errorHandler); + } + + static class Builder { + private long idleTimeout = -1; + private TimeUnit unit = null; + private ResponseAction errorHandler = null; + + public Builder() { } + + public IdlingPolicy build() { + return new IdlingPolicy(this); + } + + private Builder(IdlingPolicy copy) { + this.idleTimeout = copy.idleTimeout; + this.unit = copy.unit; + this.errorHandler = copy.errorHandler; + } + + public Builder withIdlingTimeout(long idleTimeout) { + this.idleTimeout = idleTimeout; + return this; + } + + public Builder withIdlingTimeoutUnit(TimeUnit unit) { + this.unit = unit; + return this; + } + + public Builder throwAppNotIdleException() { + this.errorHandler = ResponseAction.THROW_APP_NOT_IDLE; + return this; + } + + public Builder throwIdlingResourceTimeoutException() { + this.errorHandler = ResponseAction.THROW_IDLE_TIMEOUT; + return this; + } + + public Builder logWarning() { + this.errorHandler = ResponseAction.LOG_ERROR; + return this; + } + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/IdlingResourceTimeoutException.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/IdlingResourceTimeoutException.java new file mode 100644 index 0000000..6a9ec69 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/IdlingResourceTimeoutException.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso; + +import static com.google.common.base.Preconditions.checkNotNull; + +import java.util.List; + +/** + * Indicates that an {@link IdlingResource}, which has been registered with the framework, has not + * idled within the allowed time. + * + * Since it is not safe to proceed with test execution while the registered resource is busy (as it + * is likely to cause inconsistent results in the test), this is an unrecoverable error. The test + * author should verify that the {@link IdlingResource} interface has been implemented correctly. + */ +public final class IdlingResourceTimeoutException extends RuntimeException + implements EspressoException { + + public IdlingResourceTimeoutException(List<String> resourceNames) { + super(String.format("Wait for %s to become idle timed out", checkNotNull(resourceNames))); + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/InjectEventSecurityException.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/InjectEventSecurityException.java new file mode 100644 index 0000000..5e6158e --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/InjectEventSecurityException.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso; + +/** + * An checked {@link Exception} indicating that event injection failed with a + * {@link SecurityException}. + */ +public final class InjectEventSecurityException extends Exception implements EspressoException { + + public InjectEventSecurityException(String message) { + super(message); + } + + public InjectEventSecurityException(Throwable cause) { + super(cause); + } + + public InjectEventSecurityException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/NoActivityResumedException.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/NoActivityResumedException.java new file mode 100644 index 0000000..77f1c03 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/NoActivityResumedException.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso; + +/** + * An exception which indicates that there are no activities in stage RESUMED. + */ +public final class NoActivityResumedException extends RuntimeException + implements EspressoException { + public NoActivityResumedException(String description) { + super(description); + } + + public NoActivityResumedException(String description, Throwable cause) { + super(description, cause); + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/NoMatchingRootException.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/NoMatchingRootException.java new file mode 100644 index 0000000..9b02aa6 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/NoMatchingRootException.java @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso; + +import static com.google.common.base.Preconditions.checkNotNull; + +import org.hamcrest.Matcher; + +import java.util.List; + +/** + * Indicates that a given matcher did not match any {@link Root}s (windows) from those that are + * currently available. + */ +public final class NoMatchingRootException extends RuntimeException implements EspressoException { + + private NoMatchingRootException(String description) { + super(description); + } + + public static NoMatchingRootException create(Matcher<Root> rootMatcher, List<Root> roots) { + checkNotNull(rootMatcher); + checkNotNull(roots); + return new NoMatchingRootException(String.format( + "Matcher '%s' did not match any of the following roots: %s", rootMatcher, roots)); + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/NoMatchingViewException.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/NoMatchingViewException.java new file mode 100644 index 0000000..984f206 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/NoMatchingViewException.java @@ -0,0 +1,136 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.android.apps.common.testing.ui.espresso.util.HumanReadables; +import com.google.common.base.Optional; +import com.google.common.collect.Lists; + +import android.view.View; + +import org.hamcrest.Matcher; + +import java.util.List; + +/** + * Indicates that a given matcher did not match any elements in the view hierarchy. + * <p> + * Contains details about the matcher and the current view hierarchy to aid in debugging. + * </p> + * <p> + * Since this is usually an unrecoverable error this exception is a runtime exception. + * </p> + * <p> + * References to the view and failing matcher are purposefully not included in the state of this + * object - since it will most likely be created on the UI thread and thrown on the instrumentation + * thread, it would be invalid to touch the view on the instrumentation thread. Also the view + * hierarchy may have changed since exception creation (leading to more confusion). + * </p> + */ +public final class NoMatchingViewException extends RuntimeException implements EspressoException { + + private Matcher<? super View> viewMatcher; + private View rootView; + private List<View> adapterViews = Lists.newArrayList(); + private boolean includeViewHierarchy = true; + private Optional<String> adapterViewWarning = Optional.<String>absent(); + + private NoMatchingViewException(String description) { + super(description); + } + + private NoMatchingViewException(Builder builder) { + super(getErrorMessage(builder)); + this.viewMatcher = builder.viewMatcher; + this.rootView = builder.rootView; + this.adapterViews = builder.adapterViews; + this.adapterViewWarning = builder.adapterViewWarning; + this.includeViewHierarchy = builder.includeViewHierarchy; + } + + private static String getErrorMessage(Builder builder) { + String errorMessage = ""; + if (builder.includeViewHierarchy) { + String message = String.format("No views in hierarchy found matching: %s", + builder.viewMatcher); + if (builder.adapterViewWarning.isPresent()) { + message = message + builder.adapterViewWarning.get(); + } + errorMessage = HumanReadables.getViewHierarchyErrorMessage(builder.rootView, + null /* problemViews */, + message, + null /* problemViewSuffix */); + } else { + errorMessage = String.format("Could not find a view that matches %s" , builder.viewMatcher); + } + return errorMessage; + } + + /** Builder for {@link NoMatchingViewException}. */ + public static class Builder { + + private Matcher<? super View> viewMatcher; + private View rootView; + private List<View> adapterViews = Lists.newArrayList(); + private boolean includeViewHierarchy = true; + private Optional<String> adapterViewWarning = Optional.<String>absent(); + + public Builder from(NoMatchingViewException exception) { + this.viewMatcher = exception.viewMatcher; + this.rootView = exception.rootView; + this.adapterViews = exception.adapterViews; + this.adapterViewWarning = exception.adapterViewWarning; + this.includeViewHierarchy = exception.includeViewHierarchy; + return this; + } + + public Builder withViewMatcher(Matcher<? super View> viewMatcher) { + this.viewMatcher = viewMatcher; + return this; + } + + public Builder withRootView(View rootView) { + this.rootView = rootView; + return this; + } + + public Builder withAdapterViews(List<View> adapterViews) { + this.adapterViews = adapterViews; + return this; + } + + public Builder includeViewHierarchy(boolean includeViewHierarchy) { + this.includeViewHierarchy = includeViewHierarchy; + return this; + } + + public Builder withAdapterViewWarning(Optional<String> adapterViewWarning) { + this.adapterViewWarning = adapterViewWarning; + return this; + } + + public NoMatchingViewException build() { + checkNotNull(viewMatcher); + checkNotNull(rootView); + checkNotNull(adapterViews); + checkNotNull(adapterViewWarning); + return new NoMatchingViewException(this); + } + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/PerformException.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/PerformException.java new file mode 100644 index 0000000..ac18e77 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/PerformException.java @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso; + +import static com.google.common.base.Preconditions.checkNotNull; + +/** + * Indicates that an exception occurred while performing a ViewAction on the UI thread. + * + * A description of the {@link ViewAction}, the view being performed on and the cause are included + * in the error. Note: {@link FailureHandler}s can mutate the exception later to make it more user + * friendly. + * + * This is generally not recoverable so it is thrown on the instrumentation thread. + */ +public final class PerformException extends RuntimeException implements EspressoException { + + private static final String MESSAGE_FORMAT = "Error performing '%s' on view '%s'."; + + private final String actionDescription; + private final String viewDescription; + + private PerformException(Builder builder) { + super(String.format(MESSAGE_FORMAT, builder.actionDescription, builder.viewDescription), + builder.cause); + this.actionDescription = checkNotNull(builder.actionDescription); + this.viewDescription = checkNotNull(builder.viewDescription); + } + + public String getActionDescription() { + return actionDescription; + } + + public String getViewDescription() { + return viewDescription; + } + + /** + * Builder for {@link PerformException}. + */ + public static class Builder { + private String actionDescription; + private String viewDescription; + private Throwable cause; + + public Builder from(PerformException instance) { + this.actionDescription = instance.getActionDescription(); + this.viewDescription = instance.getViewDescription(); + this.cause = instance.getCause(); + return this; + } + + public Builder withActionDescription(String actionDescription) { + this.actionDescription = actionDescription; + return this; + } + + public Builder withViewDescription(String viewDescription) { + this.viewDescription = viewDescription; + return this; + } + + public Builder withCause(Throwable cause) { + this.cause = cause; + return this; + } + + public PerformException build() { + return new PerformException(this); + } + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/Root.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/Root.java new file mode 100644 index 0000000..0d900de --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/Root.java @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso; + +import static com.google.common.base.Objects.toStringHelper; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.android.apps.common.testing.ui.espresso.util.HumanReadables; +import com.google.common.base.Objects.ToStringHelper; +import com.google.common.base.Optional; + +import android.view.View; +import android.view.WindowManager; + +/** + * Represents a root view in the application and optionally the layout params of the window holding + * it. + * + * This class is used internally to determine which view root to run user provided matchers against + * it is not part of the public api. + */ +public final class Root { + private final View decorView; + private final Optional<WindowManager.LayoutParams> windowLayoutParams; + + private Root(Builder builder) { + this.decorView = checkNotNull(builder.decorView); + this.windowLayoutParams = Optional.fromNullable(builder.windowLayoutParams); + } + + public View getDecorView() { + return decorView; + } + + public Optional<WindowManager.LayoutParams> getWindowLayoutParams() { + return windowLayoutParams; + } + + @Override + public String toString() { + ToStringHelper helper = toStringHelper(this) + .add("application-window-token", decorView.getApplicationWindowToken()) + .add("window-token", decorView.getWindowToken()) + .add("has-window-focus", decorView.hasWindowFocus()); + if (windowLayoutParams.isPresent()) { + helper + .add("layout-params-type", windowLayoutParams.get().type) + .add("layout-params-string", windowLayoutParams.get()); + } + helper + .add("decor-view-string", HumanReadables.describe(decorView)); + return helper.toString(); + } + + public static class Builder { + private View decorView; + private WindowManager.LayoutParams windowLayoutParams; + + public Root build() { + return new Root(this); + } + + public Builder withDecorView(View view) { + this.decorView = view; + return this; + } + + public Builder withWindowLayoutParams(WindowManager.LayoutParams windowLayoutParams) { + this.windowLayoutParams = windowLayoutParams; + return this; + } + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/UiController.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/UiController.java new file mode 100644 index 0000000..cf53d08 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/UiController.java @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso; + +import android.view.KeyEvent; +import android.view.MotionEvent; + +/** + * Provides base-level UI operations (such as injection of {@link MotionEvent}s) that can be used to + * build user actions such as clicks, scrolls, swipes, etc. This replaces parts of the android + * Instrumentation class that provides similar functionality. However, it provides a more advanced + * synchronization mechanism for test actions. The key differentiators are: + * <ul> + * <li>test actions are assumed to be called on the main thread + * <li>after a test action is initiated, execution blocks until all messages in the main message + * queue have been cleared. + * </ul> + */ +public interface UiController { + /** + * Injects a motion event into the application. + * + * @param event the (non-null!) event to inject + * @return true if the event was injected, false otherwise + * @throws InjectEventSecurityException if the event couldn't be injected because it would + * interact with another application. + */ + boolean injectMotionEvent(MotionEvent event) throws InjectEventSecurityException; + + /** + * Injects a key event into the application. + * + * @param event the (non-null!) event to inject + * @return true if the event was injected, false otherwise + * @throws InjectEventSecurityException if the event couldn't be injected because it would + * interact with another application. + */ + boolean injectKeyEvent(KeyEvent event) throws InjectEventSecurityException; + + /** + * Types a string into the application using series of {@link KeyEvent}s. It is up to the + * implementor to decide how to map the string to {@link KeyEvent} objects. if you need specific + * control over the key events generated use {@link #injectKeyEvent(KeyEvent)}. + * + * @param str the (non-null!) string to type + * @return true if the string was injected, false otherwise + * @throws InjectEventSecurityException if the events couldn't be injected because it would + * interact with another application. + */ + boolean injectString(String str) throws InjectEventSecurityException; + + /** + * Loops the main thread until the application goes idle. + * + * An empty task is immediately inserted into the task queue to ensure that if we're idle at this + * moment we'll return instantly. + */ + void loopMainThreadUntilIdle(); + + /** + * Loops the main thread for a specified period of time. + * + * Control may not return immediately, instead it'll return after the time has passed and the + * queue is in an idle state again. + * + * @param millisDelay time to spend in looping the main thread + */ + void loopMainThreadForAtLeast(long millisDelay); +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/ViewAction.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/ViewAction.java new file mode 100644 index 0000000..12e607e --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/ViewAction.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso; + +import android.view.View; + +import org.hamcrest.Matcher; + +/** + * Responsible for performing an interaction on the given View element.<br> + * <p> + * This is part of the test framework public API - developers are free to write their own ViewAction + * implementations when necessary. When implementing a new ViewAction, follow these rules: + * <ul> + * <li>Inject motion events or key events via the UiController to simulate user interactions. + * <li>Do not mutate the view directly via setter methods and other state changing methods on the + * view parameter. + * <li>Do not throw AssertionErrors. Assertions belong in ViewAssertion classes. + * <li>View action code will executed on the UI thread, therefore you should not block, perform + * sleeps, or perform other expensive computations. + * <li>The test framework will wait for the UI thread to be idle both before and after perform() is + * called. This means that the action is guaranteed to be synchronized with any other view + * operations. + * <li>Downcasting the View object to an expected subtype is allowed, so long as the object + * expresses the subtype matches the constraints as specified in {@code getConstraints}. + * </ul> + */ +public interface ViewAction { + + /** + * A mechanism for ViewActions to specify what type of views they can operate on. + * + * A ViewAction can demand that the view passed to perform meets certain constraints. For example + * it may want to ensure the view is already in the viewable physical screen of the device or is + * of a certain type. + * + * @return a {@link Matcher} that will be tested prior to calling perform. + */ + public Matcher<View> getConstraints(); + + /** + * Returns a description of the view action. The description should not be overly long and should + * fit nicely in a sentence like: "performing %description% action on view with id ..." + */ + public String getDescription(); + + /** + * Performs this action on the given view. + * + * @param uiController the controller to use to interact with the UI. + * @param view the view to act upon. never null. + */ + public void perform(UiController uiController, View view); +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/ViewAssertion.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/ViewAssertion.java new file mode 100644 index 0000000..9329b57 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/ViewAssertion.java @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso; + +import android.view.View; + +import javax.annotation.Nullable; + +/** + * Responsible for performing assertions on a View element.<br> + * <p> + * This is considered part of the test framework public API - developers are free to write their own + * assertions as long as they meet the following requirements: + * <ul> + * <li>Do not mutate the passed in view. + * <li>Throw junit.framework.AssertionError when the view assertion does not hold. + * <li>Implementation runs on the UI thread - so it should not do any blocking operations + * <li>Downcasting the view to a specific type is allowed, provided there is a test that view is an + * instance of that type before downcasting. If not, an AssertionError should be thrown. + * <li>It is encouraged to access non-mutating methods on the view to perform assertion. + * </ul> + * <br> + * <p> + * Strongly consider using a existing ViewAssertion via the ViewAssertions utility class before + * writing your own assertion. + */ +public interface ViewAssertion { + + /** + * Checks the state of the given view (if such a view is present). + * + * @param view the view, if one was found during the view interaction or null if it was not + * (which may be an acceptable option for an assertion) + * @param noViewFoundException an exception detailing why the view could not be found or null if + * the view was found + */ + void check(@Nullable View view, @Nullable NoMatchingViewException noViewFoundException); +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/ViewFinder.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/ViewFinder.java new file mode 100644 index 0000000..31e0d11 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/ViewFinder.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso; + +import android.view.View; + +/** + * Uses matchers to locate particular views within the view hierarchy. + */ +public interface ViewFinder { + + /** + * Immediately locates a single view within the provided view hierarchy. + * + * If multiple views match, or if no views match the appropriate exception is thrown. + * + * @return A singular view which matches the matcher we were constructed with. + * @throws AmbiguousViewMatcherException when multiple views match + * @throws NoMatchingViewException when no views match. + */ + public View getView() throws AmbiguousViewMatcherException, NoMatchingViewException; +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/ViewInteraction.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/ViewInteraction.java new file mode 100644 index 0000000..5207083 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/ViewInteraction.java @@ -0,0 +1,178 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso; + +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isAssignableFrom; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDescendantOfA; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.android.apps.common.testing.ui.espresso.action.ScrollToAction; +import com.google.android.apps.common.testing.ui.espresso.base.MainThread; +import com.google.android.apps.common.testing.ui.espresso.util.HumanReadables; + +import android.util.Log; +import android.view.View; +import android.widget.AdapterView; + +import org.hamcrest.Matcher; +import org.hamcrest.StringDescription; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.FutureTask; +import java.util.concurrent.atomic.AtomicReference; + +import javax.inject.Inject; + +/** + * Provides the primary interface for test authors to perform actions or asserts on views. + * <p> + * Each interaction is associated with a view identified by a view matcher. All view actions and + * asserts are performed on the UI thread (thus ensuring sequential execution). The same goes for + * retrieval of views (this is done to ensure that view state is "fresh" prior to execution of each + * operation). + * <p> + */ +public final class ViewInteraction { + + private static final String TAG = ViewInteraction.class.getSimpleName(); + + private final UiController uiController; + private final ViewFinder viewFinder; + private final Executor mainThreadExecutor; + private final FailureHandler failureHandler; + private final Matcher<View> viewMatcher; + private final AtomicReference<Matcher<Root>> rootMatcherRef; + + @Inject + ViewInteraction( + UiController uiController, + ViewFinder viewFinder, + @MainThread Executor mainThreadExecutor, + FailureHandler failureHandler, + Matcher<View> viewMatcher, + AtomicReference<Matcher<Root>> rootMatcherRef) { + this.viewFinder = checkNotNull(viewFinder); + this.uiController = checkNotNull(uiController); + this.failureHandler = checkNotNull(failureHandler); + this.mainThreadExecutor = checkNotNull(mainThreadExecutor); + this.viewMatcher = checkNotNull(viewMatcher); + this.rootMatcherRef = checkNotNull(rootMatcherRef); + } + + /** + * Performs the given action(s) on the view selected by the current view matcher. If more than one + * action is provided, actions are executed in the order provided with precondition checks running + * prior to each action. + * + * @param viewActions one or more actions to execute. + * @return this interaction for further perform/verification calls. + */ + public ViewInteraction perform(final ViewAction... viewActions) { + checkNotNull(viewActions); + for (ViewAction action : viewActions) { + doPerform(action); + } + return this; + } + + + /** + * Makes this ViewInteraction scoped to the root selected by the given root matcher. + */ + public ViewInteraction inRoot(Matcher<Root> rootMatcher) { + this.rootMatcherRef.set(checkNotNull(rootMatcher)); + return this; + } + + private void doPerform(final ViewAction viewAction) { + checkNotNull(viewAction); + final Matcher<? extends View> constraints = checkNotNull(viewAction.getConstraints()); + runSynchronouslyOnUiThread(new Runnable() { + + @Override + public void run() { + uiController.loopMainThreadUntilIdle(); + View targetView = viewFinder.getView(); + Log.i(TAG, String.format( + "Performing '%s' action on view %s", viewAction.getDescription(), viewMatcher)); + if (!constraints.matches(targetView)) { + // TODO(valeraz): update this to describeMismatch once hamcrest is updated to new + // version in google3 (we are waiting for version 1.4 to avoid issues with generics) + StringDescription stringDescription = new StringDescription(new StringBuilder( + "Action will not be performed because the target view " + + "does not match one or more of the following constraints:\n")); + constraints.describeTo(stringDescription); + stringDescription.appendText("\nTarget view: ") + .appendValue(HumanReadables.describe(targetView)); + + if (viewAction instanceof ScrollToAction + && isDescendantOfA(isAssignableFrom((AdapterView.class))).matches(targetView)) { + stringDescription.appendText( + "\nFurther Info: ScrollToAction on a view inside an AdapterView will not work. " + + "Use Espresso.onData to load the view."); + } + throw new PerformException.Builder() + .withActionDescription(viewAction.getDescription()) + .withViewDescription(viewMatcher.toString()) + .withCause(new RuntimeException(stringDescription.toString())) + .build(); + } else { + viewAction.perform(uiController, targetView); + } + } + }); + } + + /** + * Checks the given {@link ViewAssertion} on the the view selected by the current view matcher. + * + * @param viewAssert the assertion to perform. + * @return this interaction for further perform/verification calls. + */ + public ViewInteraction check(final ViewAssertion viewAssert) { + checkNotNull(viewAssert); + runSynchronouslyOnUiThread(new Runnable() { + @Override + public void run() { + uiController.loopMainThreadUntilIdle(); + + View targetView = null; + NoMatchingViewException missingViewException = null; + try { + targetView = viewFinder.getView(); + } catch (NoMatchingViewException nsve) { + missingViewException = nsve; + } + viewAssert.check(targetView, missingViewException); + } + }); + return this; + } + + private void runSynchronouslyOnUiThread(Runnable action) { + FutureTask<Void> uiTask = new FutureTask<Void>(action, null); + mainThreadExecutor.execute(uiTask); + try { + uiTask.get(); + } catch (InterruptedException ie) { + throw new RuntimeException("Interrupted running UI task", ie); + } catch (ExecutionException ee) { + failureHandler.handle(ee.getCause(), viewMatcher); + } + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/ViewInteractionModule.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/ViewInteractionModule.java new file mode 100644 index 0000000..30eccc9 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/ViewInteractionModule.java @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.android.apps.common.testing.ui.espresso.base.RootViewPicker; +import com.google.android.apps.common.testing.ui.espresso.base.ViewFinderImpl; +import com.google.android.apps.common.testing.ui.espresso.matcher.RootMatchers; + +import android.view.View; + +import dagger.Module; +import dagger.Provides; + +import org.hamcrest.Matcher; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Adds the user interaction scope to the Espresso graph. + */ +@Module( + addsTo = GraphHolder.EspressoModule.class, + injects = {ViewInteraction.class}) +class ViewInteractionModule { + + private final Matcher<View> viewMatcher; + private final AtomicReference<Matcher<Root>> rootMatcher = + new AtomicReference<Matcher<Root>>(RootMatchers.DEFAULT); + + ViewInteractionModule(Matcher<View> viewMatcher) { + this.viewMatcher = checkNotNull(viewMatcher); + } + + @Provides + AtomicReference<Matcher<Root>> provideRootMatcher() { + return rootMatcher; + } + + @Provides + Matcher<View> provideViewMatcher() { + return viewMatcher; + } + + @Provides + ViewFinder provideViewFinder(ViewFinderImpl impl) { + return impl; + } + + @Provides + public View provideRootView(RootViewPicker rootViewPicker) { + // RootsOracle acts as a provider, but returning Providers is illegal, so delegate. + return rootViewPicker.get(); + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/AdapterDataLoaderAction.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/AdapterDataLoaderAction.java new file mode 100644 index 0000000..d682f6b --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/AdapterDataLoaderAction.java @@ -0,0 +1,156 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isAssignableFrom; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDisplayed; +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; +import static org.hamcrest.Matchers.allOf; + +import com.google.android.apps.common.testing.ui.espresso.PerformException; +import com.google.android.apps.common.testing.ui.espresso.UiController; +import com.google.android.apps.common.testing.ui.espresso.ViewAction; +import com.google.android.apps.common.testing.ui.espresso.util.HumanReadables; +import com.google.common.base.Optional; +import com.google.common.collect.Lists; + +import android.view.View; +import android.widget.Adapter; +import android.widget.AdapterView; + +import org.hamcrest.Matcher; +import org.hamcrest.StringDescription; + +import java.util.List; + +/** + * Forces an AdapterView to ensure that the data matching a provided data matcher + * is loaded into the current view hierarchy. + * + */ +public final class AdapterDataLoaderAction implements ViewAction { + private final Matcher<Object> dataToLoadMatcher; + private final AdapterViewProtocol adapterViewProtocol; + private final Optional<Integer> atPosition; + private AdapterViewProtocol.AdaptedData adaptedData; + private boolean performed = false; + private Object dataLock = new Object(); + + public AdapterDataLoaderAction(Matcher<Object> dataToLoadMatcher, Optional<Integer> atPosition, + AdapterViewProtocol adapterViewProtocol) { + this.dataToLoadMatcher = checkNotNull(dataToLoadMatcher); + this.atPosition = checkNotNull(atPosition); + this.adapterViewProtocol = checkNotNull(adapterViewProtocol); + } + + public AdapterViewProtocol.AdaptedData getAdaptedData() { + synchronized (dataLock) { + checkState(performed, "perform hasn't been called yet!"); + return adaptedData; + } + } + + @SuppressWarnings("unchecked") + @Override + public Matcher<View> getConstraints() { + return allOf(isAssignableFrom(AdapterView.class), isDisplayed()); + } + + @SuppressWarnings("unchecked") + @Override + public void perform(UiController uiController, View view) { + AdapterView<? extends Adapter> adapterView = (AdapterView<? extends Adapter>) view; + List<AdapterViewProtocol.AdaptedData> matchedDataItems = Lists.newArrayList(); + + for (AdapterViewProtocol.AdaptedData data : adapterViewProtocol.getDataInAdapterView( + adapterView)) { + + if (dataToLoadMatcher.matches(data.data)) { + matchedDataItems.add(data); + } + } + + if (matchedDataItems.size() == 0) { + StringDescription dataMatcherDescription = new StringDescription(); + dataToLoadMatcher.describeTo(dataMatcherDescription); + + if (matchedDataItems.isEmpty()) { + dataMatcherDescription.appendText(" contained values: "); + dataMatcherDescription.appendValue( + adapterViewProtocol.getDataInAdapterView(adapterView)); + throw new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(new RuntimeException("No data found matching: " + dataMatcherDescription)) + .build(); + } + } + + synchronized (dataLock) { + checkState(!performed, "perform called 2x!"); + performed = true; + if (atPosition.isPresent()) { + int matchedDataItemsSize = matchedDataItems.size() - 1; + if (atPosition.get() > matchedDataItemsSize) { + throw new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(new RuntimeException(String.format( + "There are only %d elements that matched but requested %d element.", + matchedDataItemsSize, atPosition.get()))) + .build(); + } else { + adaptedData = matchedDataItems.get(atPosition.get()); + } + } else { + if (matchedDataItems.size() != 1) { + StringDescription dataMatcherDescription = new StringDescription(); + dataToLoadMatcher.describeTo(dataMatcherDescription); + throw new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(new RuntimeException("Multiple data elements " + + "matched: " + dataMatcherDescription + ". Elements: " + matchedDataItems)) + .build(); + } else { + adaptedData = matchedDataItems.get(0); + } + } + } + + int requestCount = 0; + while (!adapterViewProtocol.isDataRenderedWithinAdapterView(adapterView, adaptedData)) { + if (requestCount > 1) { + if ((requestCount % 50) == 0) { + // sometimes an adapter view will receive an event that will block its attempts to scroll. + adapterView.invalidate(); + adapterViewProtocol.makeDataRenderedWithinAdapterView(adapterView, adaptedData); + } + } else { + adapterViewProtocol.makeDataRenderedWithinAdapterView(adapterView, adaptedData); + } + uiController.loopMainThreadForAtLeast(100); + requestCount++; + } + } + + @Override + public String getDescription() { + return "load adapter data"; + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/AdapterViewProtocol.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/AdapterViewProtocol.java new file mode 100644 index 0000000..c85a76d --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/AdapterViewProtocol.java @@ -0,0 +1,196 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.common.base.Preconditions.checkNotNull; +import com.google.common.base.Optional; + +import android.view.View; +import android.widget.Adapter; +import android.widget.AdapterView; + +import javax.annotation.Nullable; + +/** + * A sadly necessary layer of indirection to interact with AdapterViews. + * <p> + * Generally any subclass should respect the contracts and behaviors of its superclass. Otherwise + * it becomes impossible to work generically with objects that all claim to share a supertype - you + * need special cases to perform the same operation 'owned' by the supertype for each sub-type. The + * 'is - a' relationship is broken. + * </p> + * + * <p> + * Android breaks the Liskov substitution principal with ExpandableListView - you can't use + * getAdapter(), getItemAtPosition(), and other methods common to AdapterViews on an + * ExpandableListView because an ExpandableListView isn't an adapterView - they just share a lot of + * code. + * </p> + * + * <p> + * This interface exists to work around this wart (which sadly is copied in other projects too) and + * lets the implementor translate Espresso's needs and manipulations of the AdapterView into calls + * that make sense for the given subtype and context. + * </p> + * + * <p><i> + * If you have to implement this to talk to widgets your own project defines - I'm sorry. + * </i><p> + * + */ +public interface AdapterViewProtocol { + + /** + * Returns all data this AdapterViewProtocol can find within the given AdapterView. + * + * <p> + * Any AdaptedData returned by this method can be passed to makeDataRenderedWithinView and the + * implementation should make the AdapterView bring that data item onto the screen. + * </p> + * + * @param adapterView the AdapterView we want to interrogate the contents of. + * @return an {@link Iterable} of AdaptedDatas representing all data the implementation sees in + * this view + * @throws IllegalArgumentException if the implementation doesn't know how to manipulate the given + * adapter view. + */ + Iterable<AdaptedData> getDataInAdapterView(AdapterView<? extends Adapter> adapterView); + + /** + * Returns the data object this particular view is rendering if possible. + * + * <p> + * Implementations are expected to create a relationship between the data in the AdapterView and + * the descendant views of the AdapterView that obeys the following conditions: + * </p> + * + * <ul> + * <li>For each descendant view there exists either 0 or 1 data objects it is rendering.</li> + * <li>For each data object the AdapterView there exists either 0 or 1 descendant views which + * claim to be rendering it.</li> + * </ul> + * + * <p> For example - if a PersonObject is rendered into: </p> + * <code> + * LinearLayout + * ImageView picture + * TextView firstName + * TextView lastName + * </code> + * + * <p> + * It would be expected that getDataRenderedByView(adapter, LinearLayout) would return the + * PersonObject. If it were called instead with the TextView or ImageView it would return + * Object.absent(). + * </p> + * + * @param adapterView the adapterview hosting the data. + * @param descendantView a view which is a child, grand-child, or deeper descendant of adapterView + * @return an optional data object the descendant view is rendering. + * @throws IllegalArgumentException if this protocol cannot interrogate this class of adapterView + */ + Optional<AdaptedData> getDataRenderedByView( + AdapterView<? extends Adapter> adapterView, View descendantView); + + /** + * Requests that a particular piece of data held in this AdapterView is actually rendered by it. + * + * <p> + * After calling this method it expected that there will exist some descendant view of adapterView + * for which calling getDataRenderedByView(adapterView, descView).get() == data.data is true. + * <p> + * + * </p> + * Note: this need not happen immediately. EG: an implementor handling ListView may call + * listView.smoothScrollToPosition(data.opaqueToken) - which kicks off an animated scroll over + * the list to the given position. The animation may be in progress after this call returns. The + * only guarantee is that eventually - with no further interaction necessary - this data item + * will be rendered as a child or deeper descendant of this AdapterView. + * </p> + * + * @param adapterView the adapterView hosting the data. + * @param data an AdaptedData instance retrieved by a prior call to getDataInAdapterView + * @throws IllegalArgumentException if this protocol cannot manipulate adapterView or if data is + * not owned by this AdapterViewProtocol. + */ + void makeDataRenderedWithinAdapterView( + AdapterView<? extends Adapter> adapterView, AdaptedData data); + + + /** + * Indicates whether or not there now exists a descendant view within adapterView that + * is rendering this data. + * + * @param adapterView the AdapterView hosting this data. + * @param adaptedData the data we are checking the display state for. + * @return true if the data is rendered by a view in the adapterView, false otherwise. + */ + boolean isDataRenderedWithinAdapterView( + AdapterView<? extends Adapter> adapterView, AdaptedData adaptedData); + + + /** + * A holder that associates a data object from an AdapterView with a token the + * AdapterViewProtocol can use to force that data object to be rendered as a child or deeper + * descendant of the adapter view. + */ + public static class AdaptedData { + + /** + * One of the objects the AdapterView is exposing to the user. + */ + @Nullable + public final Object data; + + /** + * A token the implementor of AdapterViewProtocol can use to force the adapterView to display + * this data object as a child or deeper descendant in it. Equal opaqueToken point to the same + * data object on the AdapterView. + */ + public final Object opaqueToken; + + @Override + public String toString() { + return String.format("Data: %s (class: %s) token: %s", data, + null == data ? null : data.getClass(), opaqueToken); + } + + private AdaptedData(Object data, Object opaqueToken) { + this.data = data; + this.opaqueToken = checkNotNull(opaqueToken); + } + + public static class Builder { + private Object data; + private Object opaqueToken; + + public Builder withData(@Nullable Object data) { + this.data = data; + return this; + } + + public Builder withOpaqueToken(@Nullable Object opaqueToken) { + this.opaqueToken = opaqueToken; + return this; + } + + public AdaptedData build() { + return new AdaptedData(data, opaqueToken); + } + } + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/AdapterViewProtocols.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/AdapterViewProtocols.java new file mode 100644 index 0000000..5fc6032 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/AdapterViewProtocols.java @@ -0,0 +1,158 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDisplayingAtLeast; +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.common.base.Optional; +import com.google.common.collect.Lists; +import com.google.common.collect.Range; + +import android.os.Build; +import android.view.View; +import android.widget.AbsListView; +import android.widget.Adapter; +import android.widget.AdapterView; +import android.widget.AdapterViewAnimator; +import android.widget.AdapterViewFlipper; + +import java.util.List; + +/** + * Implementations of {@link AdapterViewProtocol} for standard SDK Widgets. + * + */ +public final class AdapterViewProtocols { + + /** + * Consider views which have over this percentage of their area visible to the user + * to be fully rendered. + */ + private static final int FULLY_RENDERED_PERCENTAGE_CUTOFF = 90; + + private AdapterViewProtocols() {} + + private static final AdapterViewProtocol STANDARD_PROTOCOL = new StandardAdapterViewProtocol(); + + /** + * Creates an implementation of AdapterViewProtocol that can work with AdapterViews that do not + * break method contracts on AdapterView. + * + */ + public static AdapterViewProtocol standardProtocol() { + return STANDARD_PROTOCOL; + } + + // TODO(user): expandablelistview protocols + + private static final class StandardAdapterViewProtocol implements AdapterViewProtocol { + @Override + public Iterable<AdaptedData> getDataInAdapterView(AdapterView<? extends Adapter> adapterView) { + List<AdaptedData> datas = Lists.newArrayList(); + for (int i = 0; i < adapterView.getCount(); i++) { + datas.add( + new AdaptedData.Builder() + .withData(adapterView.getItemAtPosition(i)) + .withOpaqueToken(i) + .build()); + } + return datas; + } + + @Override + public Optional<AdaptedData> getDataRenderedByView(AdapterView<? extends Adapter> adapterView, + View descendantView) { + if (adapterView == descendantView.getParent()) { + int position = adapterView.getPositionForView(descendantView); + if (position != AdapterView.INVALID_POSITION) { + return Optional.of(new AdaptedData.Builder() + .withData(adapterView.getItemAtPosition(position)) + .withOpaqueToken(Integer.valueOf(position)) + .build()); + } + } + return Optional.absent(); + } + + @Override + public void makeDataRenderedWithinAdapterView( + AdapterView<? extends Adapter> adapterView, AdaptedData data) { + checkArgument(data.opaqueToken instanceof Integer, "Not my data: %s", data); + int position = ((Integer) data.opaqueToken).intValue(); + + boolean moved = false; + // set selection should always work, we can give a little better experience if per subtype + // though. + if (Build.VERSION.SDK_INT > 7) { + if (adapterView instanceof AbsListView) { + if (Build.VERSION.SDK_INT > 10) { + ((AbsListView) adapterView).smoothScrollToPositionFromTop(position, + adapterView.getPaddingTop(), 0); + } else { + ((AbsListView) adapterView).smoothScrollToPosition(position); + } + moved = true; + } + if (Build.VERSION.SDK_INT > 10) { + if (adapterView instanceof AdapterViewAnimator) { + if (adapterView instanceof AdapterViewFlipper) { + ((AdapterViewFlipper) adapterView).stopFlipping(); + } + ((AdapterViewAnimator) adapterView).setDisplayedChild(position); + moved = true; + } + } + } + if (!moved) { + adapterView.setSelection(position); + } + } + + @SuppressWarnings("deprecation") + @Override + public boolean isDataRenderedWithinAdapterView( + AdapterView<? extends Adapter> adapterView, AdaptedData adaptedData) { + checkArgument(adaptedData.opaqueToken instanceof Integer, "Not my data: %s", adaptedData); + int dataPosition = ((Integer) adaptedData.opaqueToken).intValue(); + + if (Range.closed(adapterView.getFirstVisiblePosition(), adapterView.getLastVisiblePosition()) + .contains(dataPosition)) { + if (adapterView.getFirstVisiblePosition() == adapterView.getLastVisiblePosition()) { + // thats a huge element. + return true; + } else { + return isElementFullyRendered(adapterView, + dataPosition - adapterView.getFirstVisiblePosition()); + } + } else { + return false; + } + } + + private boolean isElementFullyRendered(AdapterView<? extends Adapter> adapterView, + int childAt) { + View element = adapterView.getChildAt(childAt); + // Occassionally we'll have to fight with smooth scrolling logic on our definition of when + // there is extra scrolling to be done. In particular if the element is the first or last + // element of the list, the smooth scroller may decide that no work needs to be done to scroll + // to the element if a certain percentage of it is on screen. Ugh. Sigh. Yuck. + + return isDisplayingAtLeast(FULLY_RENDERED_PERCENTAGE_CUTOFF).matches(element); + } + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/ClearTextAction.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/ClearTextAction.java new file mode 100644 index 0000000..e3d997a --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/ClearTextAction.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isAssignableFrom; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDisplayed; +import static org.hamcrest.Matchers.allOf; + +import com.google.android.apps.common.testing.ui.espresso.UiController; +import com.google.android.apps.common.testing.ui.espresso.ViewAction; + +import android.view.View; +import android.widget.EditText; + +import org.hamcrest.Matcher; + +/** + * Clears view text by setting {@link EditText}s text property to "". + */ +public final class ClearTextAction implements ViewAction { + @SuppressWarnings("unchecked") + @Override + public Matcher<View> getConstraints() { + return allOf(isDisplayed(), isAssignableFrom(EditText.class)); + } + + @Override + public void perform(UiController uiController, View view) { + ((EditText) view).setText(""); + } + + @Override + public String getDescription() { + return "clear text"; + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/CloseKeyboardAction.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/CloseKeyboardAction.java new file mode 100644 index 0000000..6026a68 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/CloseKeyboardAction.java @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.common.collect.Iterables.getOnlyElement; +import static org.hamcrest.Matchers.anything; + +import com.google.android.apps.common.testing.testrunner.ActivityLifecycleMonitorRegistry; +import com.google.android.apps.common.testing.testrunner.Stage; +import com.google.android.apps.common.testing.ui.espresso.PerformException; +import com.google.android.apps.common.testing.ui.espresso.UiController; +import com.google.android.apps.common.testing.ui.espresso.ViewAction; +import com.google.android.apps.common.testing.ui.espresso.util.HumanReadables; + +import android.app.Activity; +import android.content.Context; +import android.os.Bundle; +import android.os.ResultReceiver; +import android.util.Log; +import android.view.View; +import android.view.inputmethod.InputMethodManager; + +import org.hamcrest.Matcher; + +import java.util.Collection; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Closes soft keyboard. + */ +public final class CloseKeyboardAction implements ViewAction { + + private static final int NUM_RETRIES = 3; + private static final String TAG = CloseKeyboardAction.class.getSimpleName(); + + @SuppressWarnings("unchecked") + @Override + public Matcher<View> getConstraints() { + return anything(); + } + + @Override + public void perform(UiController uiController, View view) { + // Retry in case of timeout exception to avoid flakiness in IMM. + for (int i = 0; i < NUM_RETRIES; i++) { + try { + tryToCloseKeyboard(view, uiController); + return; + } catch (TimeoutException te) { + Log.w(TAG, "Caught timeout exception. Retrying."); + if (i == 2) { + throw new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(te) + .build(); + } + } + } + } + + private void tryToCloseKeyboard(View view, UiController uiController) throws TimeoutException { + InputMethodManager imm = (InputMethodManager) getRootActivity(uiController) + .getSystemService(Context.INPUT_METHOD_SERVICE); + final AtomicInteger atomicResultCode = new AtomicInteger(); + final CountDownLatch latch = new CountDownLatch(1); + + ResultReceiver result = new ResultReceiver(null) { + @Override + protected void onReceiveResult(int resultCode, Bundle resultData) { + atomicResultCode.set(resultCode); + latch.countDown(); + } + }; + + if (!imm.hideSoftInputFromWindow(view.getWindowToken(), 0, result)) { + Log.w(TAG, "Attempting to close soft keyboard, while it is not shown."); + return; + } + + try { + if (!latch.await(2, TimeUnit.SECONDS)) { + throw new TimeoutException("Wait on operation result timed out."); + } + } catch (InterruptedException e) { + throw new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(new RuntimeException("Waiting for soft keyboard close result was interrupted.")) + .build(); + } + + if (atomicResultCode.get() != InputMethodManager.RESULT_UNCHANGED_HIDDEN + && atomicResultCode.get() != InputMethodManager.RESULT_HIDDEN) { + String error = + "Attempt to close the soft keyboard did not result in soft keyboard to be hidden." + + "resultCode = " + atomicResultCode.get(); + Log.e(TAG, error); + throw new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(new RuntimeException(error)) + .build(); + } + } + + private static Activity getRootActivity(UiController uiController) { + Collection<Activity> resumedActivities = + ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESUMED); + if (resumedActivities.isEmpty()) { + uiController.loopMainThreadUntilIdle(); + resumedActivities = + ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESUMED); + } + Activity topActivity = getOnlyElement(resumedActivities); + return topActivity; + } + + @Override + public String getDescription() { + return "close keyboard"; + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/CoordinatesProvider.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/CoordinatesProvider.java new file mode 100644 index 0000000..c8c9823 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/CoordinatesProvider.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.action; + +import android.view.View; + +/** + * Interface to implement calculation of Coordinates. + */ +public interface CoordinatesProvider { + + /** + * Calculates coordinates of given view. + * + * @param view the View which is used for the calculation. + * @return a float[] with x and y values of the calculated coordinates. + */ + public float[] calculateCoordinates(View view); +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/EditorAction.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/EditorAction.java new file mode 100644 index 0000000..6b78cb9 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/EditorAction.java @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDisplayed; + +import com.google.android.apps.common.testing.ui.espresso.PerformException; +import com.google.android.apps.common.testing.ui.espresso.UiController; +import com.google.android.apps.common.testing.ui.espresso.ViewAction; +import com.google.android.apps.common.testing.ui.espresso.util.HumanReadables; + +import android.view.View; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; + +import org.hamcrest.Matcher; + +/** + * Performs whatever editor (IME) action is available on a view. + */ +public final class EditorAction implements ViewAction { + + @Override + public Matcher<View> getConstraints() { + return isDisplayed(); + } + + @Override + public String getDescription() { + return "input method editor"; + } + + @Override + public void perform(UiController uiController, View view) { + EditorInfo editorInfo = new EditorInfo(); + InputConnection inputConnection = view.onCreateInputConnection(editorInfo); + if (inputConnection == null) { + throw new PerformException.Builder() + .withActionDescription(this.toString()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(new IllegalStateException("View does not support input methods")) + .build(); + } + + int actionId = editorInfo.actionId != 0 ? editorInfo.actionId : + editorInfo.imeOptions & EditorInfo.IME_MASK_ACTION; + + if (actionId == EditorInfo.IME_ACTION_NONE) { + throw new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(new IllegalStateException("No available action on view")) + .build(); + } + + if (!inputConnection.performEditorAction(actionId)) { + throw new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(new RuntimeException(String.format( + "Failed to perform action %#x. Input connection no longer valid", actionId))) + .build(); + } + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/EspressoKey.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/EspressoKey.java new file mode 100644 index 0000000..530ddde --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/EspressoKey.java @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.common.base.Preconditions.checkState; + +import android.os.Build; +import android.view.KeyEvent; + +/** + * Class that wraps the key code and meta state of the desired key press. + */ +public final class EspressoKey { + private final int keyCode; + private final int metaState; + + private EspressoKey(Builder builder) { + this.keyCode = builder.builderKeyCode; + this.metaState = builder.getMetaState(); + } + + public int getKeyCode() { + return keyCode; + } + + public int getMetaState() { + return metaState; + } + + @Override + public String toString() { + return String.format("keyCode: %s, metaState: %s", keyCode, metaState); + } + + /** + * Builder for the EspressoKey class. + */ + public static class Builder { + private int builderKeyCode = -1; + private boolean isShiftPressed; + private boolean isAltPressed; + private boolean isCtrlPressed; + + public Builder withKeyCode(int keyCode) { + builderKeyCode = keyCode; + return this; + } + + /** + * Sets the SHIFT_ON meta state of the resulting key. + */ + public Builder withShiftPressed(boolean shiftPressed) { + isShiftPressed = shiftPressed; + return this; + } + + /** + * On Honeycomb and above, sets the CTRL_ON meta state of the resulting key. On Gingerbread and + * below, this is a noop. + */ + public Builder withCtrlPressed(boolean ctrlPressed) { + isCtrlPressed = ctrlPressed; + return this; + } + + /** + * Sets the ALT_ON meta state of the resulting key. + */ + public Builder withAltPressed(boolean altPressed) { + isAltPressed = altPressed; + return this; + } + + private int getMetaState() { + int metaState = 0; + if (isShiftPressed) { + metaState |= KeyEvent.META_SHIFT_ON; + } + + if (isAltPressed) { + metaState |= KeyEvent.META_ALT_ON; + } + + if (isCtrlPressed && Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { + metaState |= KeyEvent.META_CTRL_ON; + } + + return metaState; + } + + public EspressoKey build() { + checkState(builderKeyCode > 0 && builderKeyCode < KeyEvent.getMaxKeyCode(), + "Invalid key code: %s", builderKeyCode); + return new EspressoKey(this); + } + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/GeneralClickAction.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/GeneralClickAction.java new file mode 100644 index 0000000..857501d --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/GeneralClickAction.java @@ -0,0 +1,141 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDisplayingAtLeast; +import static org.hamcrest.Matchers.allOf; + +import com.google.android.apps.common.testing.ui.espresso.PerformException; +import com.google.android.apps.common.testing.ui.espresso.UiController; +import com.google.android.apps.common.testing.ui.espresso.ViewAction; +import com.google.android.apps.common.testing.ui.espresso.util.HumanReadables; +import com.google.common.base.Optional; + +import android.view.View; +import android.view.ViewConfiguration; +import android.webkit.WebView; + +import org.hamcrest.Matcher; + +/** + * Enables clicking on views. + */ +public final class GeneralClickAction implements ViewAction { + + private final CoordinatesProvider coordinatesProvider; + private final Tapper tapper; + private final PrecisionDescriber precisionDescriber; + private final Optional<ViewAction> rollbackAction; + + public GeneralClickAction(Tapper tapper, CoordinatesProvider coordinatesProvider, + PrecisionDescriber precisionDescriber) { + this(tapper, coordinatesProvider, precisionDescriber, null); + } + + public GeneralClickAction(Tapper tapper, CoordinatesProvider coordinatesProvider, + PrecisionDescriber precisionDescriber, ViewAction rollbackAction) { + this.coordinatesProvider = coordinatesProvider; + this.tapper = tapper; + this.precisionDescriber = precisionDescriber; + this.rollbackAction = Optional.fromNullable(rollbackAction); + } + + @Override + @SuppressWarnings("unchecked") + public Matcher<View> getConstraints() { + Matcher<View> standardConstraint = isDisplayingAtLeast(90); + if (rollbackAction.isPresent()) { + return allOf(standardConstraint, rollbackAction.get().getConstraints()); + } else { + return standardConstraint; + } + } + + @Override + public void perform(UiController uiController, View view) { + float[] coordinates = coordinatesProvider.calculateCoordinates(view); + float[] precision = precisionDescriber.describePrecision(); + + Tapper.Status status = Tapper.Status.FAILURE; + int loopCount = 0; + // Native event injection is quite a tricky process. A tap is actually 2 + // seperate motion events which need to get injected into the system. Injection + // makes an RPC call from our app under test to the Android system server, the + // system server decides which window layer to deliver the event to, the system + // server makes an RPC to that window layer, that window layer delivers the event + // to the correct UI element, activity, or window object. Now we need to repeat + // that 2x. for a simple down and up. Oh and the down event triggers timers to + // detect whether or not the event is a long vs. short press. The timers are + // removed the moment the up event is received (NOTE: the possibility of eventTime + // being in the future is totally ignored by most motion event processors). + // + // Phew. + // + // The net result of this is sometimes we'll want to do a regular tap, and for + // whatever reason the up event (last half) of the tap is delivered after long + // press timeout (depending on system load) and the long press behaviour is + // displayed (EG: show a context menu). There is no way to avoid or handle this more + // gracefully. Also the longpress behavour is app/widget specific. So if you have + // a seperate long press behaviour from your short press, you can pass in a + // 'RollBack' ViewAction which when executed will undo the effects of long press. + + while (status != Tapper.Status.SUCCESS && loopCount < 3) { + try { + status = tapper.sendTap(uiController, coordinates, precision); + } catch (RuntimeException re) { + throw new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(re) + .build(); + } + + // ensures that all work enqueued to process the tap has been run. + uiController.loopMainThreadForAtLeast(ViewConfiguration.getPressedStateDuration()); + if (status == Tapper.Status.WARNING) { + if (rollbackAction.isPresent()) { + rollbackAction.get().perform(uiController, view); + } else { + break; + } + } + loopCount++; + } + if (status == Tapper.Status.FAILURE) { + throw new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(new RuntimeException(String.format("Couldn't " + + "click at: %s,%s precision: %s, %s . Tapper: %s coordinate provider: %s precision " + + "describer: %s. Tried %s times. With Rollback? %s", coordinates[0], coordinates[1], + precision[0], precision[1], tapper, coordinatesProvider, precisionDescriber, loopCount, + rollbackAction.isPresent()))) + .build(); + } + + if (tapper == Tap.SINGLE && view instanceof WebView) { + // WebViews will not process click events until double tap + // timeout. Not the best place for this - but good for now. + uiController.loopMainThreadForAtLeast(ViewConfiguration.getDoubleTapTimeout()); + } + } + + @Override + public String getDescription() { + return tapper.toString().toLowerCase() + " click"; + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/GeneralLocation.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/GeneralLocation.java new file mode 100644 index 0000000..f74775e --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/GeneralLocation.java @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.action; + +import android.view.View; + +/** + * Calculates coordinate position for general locations. + */ +public enum GeneralLocation implements CoordinatesProvider { + + TOP_LEFT { + @Override + public float[] calculateCoordinates(View view) { + return getCoordinates(view, Position.BEGIN, Position.BEGIN); + } + }, + TOP_CENTER { + @Override + public float[] calculateCoordinates(View view) { + return getCoordinates(view, Position.BEGIN, Position.MIDDLE); + } + }, + TOP_RIGHT { + @Override + public float[] calculateCoordinates(View view) { + return getCoordinates(view, Position.BEGIN, Position.END); + } + }, + CENTER_LEFT { + @Override + public float[] calculateCoordinates(View view) { + return getCoordinates(view, Position.MIDDLE, Position.BEGIN); + } + }, + CENTER { + @Override + public float[] calculateCoordinates(View view) { + return getCoordinates(view, Position.MIDDLE, Position.MIDDLE); + } + }, + CENTER_RIGHT { + @Override + public float[] calculateCoordinates(View view) { + return getCoordinates(view, Position.MIDDLE, Position.END); + } + }, + BOTTOM_LEFT { + @Override + public float[] calculateCoordinates(View view) { + return getCoordinates(view, Position.END, Position.BEGIN); + } + }, + BOTTOM_CENTER { + @Override + public float[] calculateCoordinates(View view) { + return getCoordinates(view, Position.END, Position.MIDDLE); + } + }, + BOTTOM_RIGHT { + @Override + public float[] calculateCoordinates(View view) { + return getCoordinates(view, Position.END, Position.END); + } + }; + + private static float[] getCoordinates(View view, Position vertical, Position horizontal) { + final int[] xy = new int[2]; + view.getLocationOnScreen(xy); + final float x = horizontal.getPosition(xy[0], view.getWidth()); + final float y = vertical.getPosition(xy[1], view.getHeight()); + float[] coordinates = {x, y}; + return coordinates; + } + + private static enum Position { + BEGIN { + @Override + public float getPosition(int viewPos, int viewLength) { + return viewPos; + } + }, + MIDDLE { + @Override + public float getPosition(int viewPos, int viewLength) { + return viewPos + (viewLength / 2.0f); + } + }, + END { + @Override + public float getPosition(int viewPos, int viewLength) { + return viewPos + viewLength; + } + }; + + abstract float getPosition(int widgetPos, int widgetLength); + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/GeneralSwipeAction.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/GeneralSwipeAction.java new file mode 100644 index 0000000..6482250 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/GeneralSwipeAction.java @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDisplayingAtLeast; + +import com.google.android.apps.common.testing.ui.espresso.PerformException; +import com.google.android.apps.common.testing.ui.espresso.UiController; +import com.google.android.apps.common.testing.ui.espresso.ViewAction; +import com.google.android.apps.common.testing.ui.espresso.util.HumanReadables; + +import android.view.View; +import android.view.ViewConfiguration; + +import org.hamcrest.Matcher; + +/** + * Enables swiping across a view. + */ +public final class GeneralSwipeAction implements ViewAction { + + /** Maximum number of times to attempt sending a swipe action. */ + private static final int MAX_TRIES = 3; + + /** The minimum amount of a view that must be displayed in order to swipe across it. */ + private static final int VIEW_DISPLAY_PERCENTAGE = 90; + + private final CoordinatesProvider startCoordinatesProvider; + private final CoordinatesProvider endCoordinatesProvider; + private final Swiper swiper; + private final PrecisionDescriber precisionDescriber; + + public GeneralSwipeAction(Swiper swiper, CoordinatesProvider startCoordinatesProvider, + CoordinatesProvider endCoordinatesProvider, PrecisionDescriber precisionDescriber) { + this.swiper = swiper; + this.startCoordinatesProvider = startCoordinatesProvider; + this.endCoordinatesProvider = endCoordinatesProvider; + this.precisionDescriber = precisionDescriber; + } + + @Override + public Matcher<View> getConstraints() { + return isDisplayingAtLeast(VIEW_DISPLAY_PERCENTAGE); + } + + @Override + public void perform(UiController uiController, View view) { + float[] startCoordinates = startCoordinatesProvider.calculateCoordinates(view); + float[] endCoordinates = endCoordinatesProvider.calculateCoordinates(view); + float[] precision = precisionDescriber.describePrecision(); + + Swiper.Status status = Swiper.Status.FAILURE; + + for (int tries = 0; tries < MAX_TRIES && status != Swiper.Status.SUCCESS; tries++) { + try { + status = swiper.sendSwipe(uiController, startCoordinates, endCoordinates, precision); + } catch (RuntimeException re) { + throw new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(re) + .build(); + } + + // ensures that all work enqueued to process the swipe has been run. + uiController.loopMainThreadForAtLeast(ViewConfiguration.getPressedStateDuration()); + } + + if (status == Swiper.Status.FAILURE) { + throw new PerformException.Builder() + .withActionDescription(getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(new RuntimeException(String.format( + "Couldn't swipe from: %s,%s to: %s,%s precision: %s, %s . Swiper: %s " + + "start coordinate provider: %s precision describer: %s. Tried %s times", + startCoordinates[0], + startCoordinates[1], + endCoordinates[0], + endCoordinates[1], + precision[0], + precision[1], + swiper, + startCoordinatesProvider, + precisionDescriber, + MAX_TRIES))) + .build(); + } + } + + @Override + public String getDescription() { + return swiper.toString().toLowerCase() + " swipe"; + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/KeyEventAction.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/KeyEventAction.java new file mode 100644 index 0000000..1be85f7 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/KeyEventAction.java @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDisplayed; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.android.apps.common.testing.testrunner.ActivityLifecycleMonitorRegistry; +import com.google.android.apps.common.testing.testrunner.Stage; +import com.google.android.apps.common.testing.ui.espresso.InjectEventSecurityException; +import com.google.android.apps.common.testing.ui.espresso.NoActivityResumedException; +import com.google.android.apps.common.testing.ui.espresso.PerformException; +import com.google.android.apps.common.testing.ui.espresso.UiController; +import com.google.android.apps.common.testing.ui.espresso.ViewAction; +import com.google.android.apps.common.testing.ui.espresso.util.HumanReadables; + +import android.os.SystemClock; +import android.util.Log; +import android.view.KeyEvent; +import android.view.View; + +import org.hamcrest.Matcher; + +/** + * Enables pressing KeyEvents on views. + */ +public final class KeyEventAction implements ViewAction { + private static final String TAG = KeyEventAction.class.getSimpleName(); + + private final EspressoKey key; + + public KeyEventAction(EspressoKey key) { + this.key = checkNotNull(key); + } + + @SuppressWarnings("unchecked") + @Override + public Matcher<View> getConstraints() { + return isDisplayed(); + } + + @Override + public void perform(UiController uiController, View view) { + try { + if (!sendKeyEvent(uiController, view)) { + Log.e(TAG, "Failed to inject key event: " + this.key); + throw new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(new RuntimeException("Failed to inject key event " + this.key)) + .build(); + } + } catch (InjectEventSecurityException e) { + Log.e(TAG, "Failed to inject key event: " + this.key); + throw new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(e) + .build(); + } + } + + private final boolean sendKeyEvent(UiController controller, View view) + throws InjectEventSecurityException { + + boolean injected = false; + long eventTime = SystemClock.uptimeMillis(); + for (int attempts = 0; !injected && attempts < 4; attempts++) { + injected = controller.injectKeyEvent(new KeyEvent(eventTime, + eventTime, + KeyEvent.ACTION_DOWN, + this.key.getKeyCode(), + 0, + this.key.getMetaState())); + } + + if (!injected) { + // it is not a transient failure... :( + return false; + } + + injected = false; + eventTime = SystemClock.uptimeMillis(); + for (int attempts = 0; !injected && attempts < 4; attempts++) { + injected = controller.injectKeyEvent( + new KeyEvent(eventTime, eventTime, KeyEvent.ACTION_UP, this.key.getKeyCode(), 0)); + } + + + if (this.key.getKeyCode() == KeyEvent.KEYCODE_BACK) { + controller.loopMainThreadUntilIdle(); + boolean activeActivities = !ActivityLifecycleMonitorRegistry.getInstance() + .getActivitiesInStage(Stage.RESUMED) + .isEmpty(); + if (!activeActivities) { + Throwable cause = new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .build(); + throw new NoActivityResumedException("Pressed back and killed the app", cause); + } + } + + return injected; + } + + @Override + public String getDescription() { + return String.format("send %s key event", this.key); + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/MotionEvents.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/MotionEvents.java new file mode 100644 index 0000000..1f38339 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/MotionEvents.java @@ -0,0 +1,249 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.android.apps.common.testing.testrunner.UsageTrackerRegistry; +import com.google.android.apps.common.testing.ui.espresso.InjectEventSecurityException; +import com.google.android.apps.common.testing.ui.espresso.PerformException; +import com.google.android.apps.common.testing.ui.espresso.UiController; +import com.google.common.annotations.VisibleForTesting; + +import android.os.SystemClock; +import android.util.Log; +import android.view.MotionEvent; +import android.view.ViewConfiguration; + +/** + * Facilitates sending of motion events to a {@link UiController}. + */ +final class MotionEvents { + + private static final String TAG = MotionEvents.class.getSimpleName(); + + @VisibleForTesting + static final int MAX_CLICK_ATTEMPTS = 3; + + private MotionEvents() { + // Shouldn't be instantiated + } + + static DownResultHolder sendDown( + UiController uiController, float[] coordinates, float[] precision) { + checkNotNull(uiController); + checkNotNull(coordinates); + checkNotNull(precision); + + for (int retry = 0; retry < MAX_CLICK_ATTEMPTS; retry++) { + MotionEvent motionEvent = null; + try { + // Algorithm of sending click event adopted from android.test.TouchUtils. + // When the click event was first initiated. Needs to be same for both down and up press + // events. + long downTime = SystemClock.uptimeMillis(); + + // Down press. + motionEvent = MotionEvent.obtain(downTime, + SystemClock.uptimeMillis(), + MotionEvent.ACTION_DOWN, + coordinates[0], + coordinates[1], + 0, // pressure + 1, // size + 0, // metaState + precision[0], // xPrecision + precision[1], // yPrecision + 0, // deviceId + 0); // edgeFlags + // The down event should be considered a tap if it is long enough to be detected + // but short enough not to be a long-press. Assume that TapTimeout is set at least + // twice the detection time for a tap (no need to sleep for the whole TapTimeout since + // we aren't concerned about scrolling here). + long isTapAt = downTime + (ViewConfiguration.getTapTimeout() / 2); + + boolean injectEventSucceeded = uiController.injectMotionEvent(motionEvent); + + while (true) { + long delayToBeTap = isTapAt - SystemClock.uptimeMillis(); + if (delayToBeTap <= 10) { + break; + } + // Sleep only a fraction of the time, since there may be other events in the UI queue + // that could cause us to start sleeping late, and then oversleep. + uiController.loopMainThreadForAtLeast(delayToBeTap / 4); + } + + boolean longPress = false; + if (SystemClock.uptimeMillis() > (downTime + ViewConfiguration.getLongPressTimeout())) { + longPress = true; + Log.e(TAG, "Overslept and turned a tap into a long press"); + UsageTrackerRegistry.getInstance().trackUsage("Espresso.Tap.Error.tapToLongPress"); + } + + if (!injectEventSucceeded) { + motionEvent.recycle(); + motionEvent = null; + continue; + } + + return new DownResultHolder(motionEvent, longPress); + } catch (InjectEventSecurityException e) { + throw new PerformException.Builder() + .withActionDescription("Send down montion event") + .withViewDescription("unknown") // likely to be replaced by FailureHandler + .withCause(e) + .build(); + } + } + throw new PerformException.Builder() + .withActionDescription(String.format("click (after %s attempts)", MAX_CLICK_ATTEMPTS)) + .withViewDescription("unknown") // likely to be replaced by FailureHandler + .build(); + } + + static boolean sendUp(UiController uiController, MotionEvent downEvent) { + return sendUp(uiController, downEvent, new float[] { downEvent.getX(), downEvent.getY() }); + } + + static boolean sendUp(UiController uiController, MotionEvent downEvent, float[] coordinates) { + checkNotNull(uiController); + checkNotNull(downEvent); + checkNotNull(coordinates); + + MotionEvent motionEvent = null; + try { + // Up press. + motionEvent = MotionEvent.obtain(downEvent.getDownTime(), + SystemClock.uptimeMillis(), + MotionEvent.ACTION_UP, + coordinates[0], + coordinates[1], + 0); + boolean injectEventSucceeded = uiController.injectMotionEvent(motionEvent); + + if (!injectEventSucceeded) { + Log.e(TAG, String.format( + "Injection of up event failed (corresponding down event: %s)", downEvent.toString())); + return false; + } + } catch (InjectEventSecurityException e) { + throw new PerformException.Builder() + .withActionDescription( + String.format("inject up event (corresponding down event: %s)", downEvent.toString())) + .withViewDescription("unknown") // likely to be replaced by FailureHandler + .withCause(e) + .build(); + } finally { + if (null != motionEvent) { + motionEvent.recycle(); + motionEvent = null; + } + } + return true; + } + + static void sendCancel(UiController uiController, MotionEvent downEvent) { + checkNotNull(uiController); + checkNotNull(downEvent); + + MotionEvent motionEvent = null; + try { + // Up press. + motionEvent = MotionEvent.obtain(downEvent.getDownTime(), + SystemClock.uptimeMillis(), + MotionEvent.ACTION_CANCEL, + downEvent.getX(), + downEvent.getY(), + 0); + boolean injectEventSucceeded = uiController.injectMotionEvent(motionEvent); + + if (!injectEventSucceeded) { + throw new PerformException.Builder() + .withActionDescription(String.format( + "inject cancel event (corresponding down event: %s)", downEvent.toString())) + .withViewDescription("unknown") // likely to be replaced by FailureHandler + .build(); + } + } catch (InjectEventSecurityException e) { + throw new PerformException.Builder() + .withActionDescription(String.format( + "inject cancel event (corresponding down event: %s)", downEvent.toString())) + .withViewDescription("unknown") // likely to be replaced by FailureHandler + .withCause(e) + .build(); + } finally { + if (null != motionEvent) { + motionEvent.recycle(); + motionEvent = null; + } + } + } + + static boolean sendMovement(UiController uiController, MotionEvent downEvent, + float[] coordinates) { + checkNotNull(uiController); + checkNotNull(downEvent); + checkNotNull(coordinates); + + MotionEvent motionEvent = null; + try { + motionEvent = MotionEvent.obtain(downEvent.getDownTime(), + SystemClock.uptimeMillis(), + MotionEvent.ACTION_MOVE, + coordinates[0], + coordinates[1], + 0); + boolean injectEventSucceeded = uiController.injectMotionEvent(motionEvent); + + if (!injectEventSucceeded) { + Log.e(TAG, String.format( + "Injection of motion event failed (corresponding down event: %s)", + downEvent.toString())); + return false; + } + } catch (InjectEventSecurityException e) { + throw new PerformException.Builder() + .withActionDescription(String.format( + "inject motion event (corresponding down event: %s)", downEvent.toString())) + .withViewDescription("unknown") // likely to be replaced by FailureHandler + .withCause(e) + .build(); + } finally { + if (null != motionEvent) { + motionEvent.recycle(); + motionEvent = null; + } + } + + return true; + } + + /** + * Holds the result of a down motion. + */ + static class DownResultHolder { + public final MotionEvent down; + public final boolean longPress; + + DownResultHolder(MotionEvent down, boolean longPress) { + this.down = down; + this.longPress = longPress; + } + } + +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/PrecisionDescriber.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/PrecisionDescriber.java new file mode 100644 index 0000000..422de8e --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/PrecisionDescriber.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.action; + +/** + * Interface to implement size of click area. + */ +public interface PrecisionDescriber { + + /** + * Different touch target sizes. + * + * @return a float[] with x and y values of size of click area. + */ + public float[] describePrecision(); +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/Press.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/Press.java new file mode 100644 index 0000000..883c852 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/Press.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.action; + +/** + * Returns different touch target sizes. + */ +public enum Press implements PrecisionDescriber { + PINPOINT { + @Override + public float[] describePrecision() { + float[] pinpoint = {1f, 1f}; + return pinpoint; + } + }, + FINGER { + // average width of the index finger is 16 – 20 mm. + @Override + public float[] describePrecision() { + float finger[] = {16f, 16f}; + return finger; + } + }, + // average width of an adult thumb is 25 mm (1 inch). + THUMB { + @Override + public float[] describePrecision() { + float thumb[] = {25f, 25f}; + return thumb; + } + }; +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/ScrollToAction.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/ScrollToAction.java new file mode 100644 index 0000000..9d613c3 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/ScrollToAction.java @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isAssignableFrom; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDescendantOfA; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDisplayingAtLeast; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withEffectiveVisibility; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.anyOf; + +import com.google.android.apps.common.testing.ui.espresso.PerformException; +import com.google.android.apps.common.testing.ui.espresso.UiController; +import com.google.android.apps.common.testing.ui.espresso.ViewAction; +import com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.Visibility; +import com.google.android.apps.common.testing.ui.espresso.util.HumanReadables; + +import android.graphics.Rect; +import android.util.Log; +import android.view.View; +import android.widget.HorizontalScrollView; +import android.widget.ScrollView; + +import org.hamcrest.Matcher; + +/** + * Enables scrolling to the given view. View must be a descendant of a ScrollView. + */ +public final class ScrollToAction implements ViewAction { + private static final String TAG = ScrollToAction.class.getSimpleName(); + + @SuppressWarnings("unchecked") + @Override + public Matcher<View> getConstraints() { + return allOf(withEffectiveVisibility(Visibility.VISIBLE), isDescendantOfA(anyOf( + isAssignableFrom(ScrollView.class), isAssignableFrom(HorizontalScrollView.class)))); + } + + @Override + public void perform(UiController uiController, View view) { + if (isDisplayingAtLeast(90).matches(view)) { + Log.i(TAG, "View is already displayed. Returning."); + return; + } + Rect rect = new Rect(); + view.getDrawingRect(rect); + if (!view.requestRectangleOnScreen(rect, true /* immediate */)) { + Log.w(TAG, "Scrolling to view was requested, but none of the parents scrolled."); + } + uiController.loopMainThreadUntilIdle(); + if (!isDisplayingAtLeast(90).matches(view)) { + throw new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(new RuntimeException( + "Scrolling to view was attempted, but the view is not displayed")) + .build(); + } + } + + @Override + public String getDescription() { + return "scroll to"; + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/Swipe.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/Swipe.java new file mode 100644 index 0000000..06e8dc8 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/Swipe.java @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.common.base.Preconditions.checkElementIndex; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.android.apps.common.testing.ui.espresso.UiController; + +import android.os.SystemClock; +import android.util.Log; +import android.view.MotionEvent; + +/** + * Executes different swipe types to given positions. + */ +public enum Swipe implements Swiper { + + /** Swipes quickly between the co-ordinates. */ + FAST { + @Override + public Swiper.Status sendSwipe(UiController uiController, float[] startCoordinates, + float[] endCoordinates, float[] precision) { + return sendLinearSwipe(uiController, startCoordinates, endCoordinates, precision, + SWIPE_FAST_DURATION_MS); + } + }, + + /** Swipes deliberately slowly between the co-ordinates, to aid in visual debugging. */ + SLOW { + @Override + public Swiper.Status sendSwipe(UiController uiController, float[] startCoordinates, + float[] endCoordinates, float[] precision) { + return sendLinearSwipe(uiController, startCoordinates, endCoordinates, precision, + SWIPE_SLOW_DURATION_MS); + } + }; + + private static final String TAG = Swipe.class.getSimpleName(); + + /** The number of motion events to send for each swipe. */ + private static final int SWIPE_EVENT_COUNT = 10; + + /** Length of time a "fast" swipe should last for, in milliseconds. */ + private static final int SWIPE_FAST_DURATION_MS = 100; + + /** Length of time a "slow" swipe should last for, in milliseconds. */ + private static final int SWIPE_SLOW_DURATION_MS = 1500; + + private static float[][] interpolate(float[] start, float[] end, int steps) { + checkElementIndex(1, start.length); + checkElementIndex(1, end.length); + + float[][] res = new float[steps][2]; + + for (int i = 1; i < steps + 1; i++) { + res[i - 1][0] = start[0] + (end[0] - start[0]) * i / (steps + 2f); + res[i - 1][1] = start[1] + (end[1] - start[1]) * i / (steps + 2f); + } + + return res; + } + + private static Swiper.Status sendLinearSwipe(UiController uiController, float[] startCoordinates, + float[] endCoordinates, float[] precision, int duration) { + checkNotNull(uiController); + checkNotNull(startCoordinates); + checkNotNull(endCoordinates); + checkNotNull(precision); + + float[][] steps = interpolate(startCoordinates, endCoordinates, SWIPE_EVENT_COUNT); + final int delayBetweenMovements = duration / steps.length; + + MotionEvent downEvent = MotionEvents.sendDown(uiController, steps[0], precision).down; + try { + for (int i = 1; i < steps.length; i++) { + if (!MotionEvents.sendMovement(uiController, downEvent, steps[i])) { + Log.e(TAG, "Injection of move event as part of the swipe failed. Sending cancel event."); + MotionEvents.sendCancel(uiController, downEvent); + return Swiper.Status.FAILURE; + } + + long desiredTime = downEvent.getDownTime() + delayBetweenMovements * i; + long timeUntilDesired = desiredTime - SystemClock.uptimeMillis(); + if (timeUntilDesired > 10) { + uiController.loopMainThreadForAtLeast(timeUntilDesired); + } + } + + if (!MotionEvents.sendUp(uiController, downEvent, endCoordinates)) { + Log.e(TAG, "Injection of up event as part of the swipe failed. Sending cancel event."); + MotionEvents.sendCancel(uiController, downEvent); + return Swiper.Status.FAILURE; + } + } finally { + downEvent.recycle(); + } + return Swiper.Status.SUCCESS; + } + +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/Swiper.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/Swiper.java new file mode 100644 index 0000000..41ac593 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/Swiper.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.action; + +import com.google.android.apps.common.testing.ui.espresso.UiController; + +import android.view.MotionEvent; + +/** + * Interface to implement different swipe types. + */ +public interface Swiper { + + /** + * The result of the swipe. + */ + public enum Status { + /** + * The swipe action completed successfully. + */ + SUCCESS, + /** + * Injecting the event was a complete failure. + */ + FAILURE + } + + /** + * Swipes from {@code startCoordinates} to {@code endCoordinates} using the given + * {@code uiController} to send {@link MotionEvent}s. + * + * @param uiController a UiController to use to send MotionEvents to the screen. + * @param startCoordinates a float[] with x and y co-ordinates of the start of the swipe. + * @param endCoordinates a float[] with x and y co-ordinates of the end of the swipe. + * @param precision a float[] with x and y values of precision of the tap. + * @return The status of the swipe. + */ + public Status sendSwipe(UiController uiController, float[] startCoordinates, + float[] endCoordinates, float[] precision); + +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/Tap.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/Tap.java new file mode 100644 index 0000000..712f8cc --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/Tap.java @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.android.apps.common.testing.ui.espresso.UiController; +import com.google.android.apps.common.testing.ui.espresso.action.MotionEvents.DownResultHolder; + +import android.util.Log; +import android.view.MotionEvent; +import android.view.ViewConfiguration; + +/** + * Executes different click types to given position. + */ +public enum Tap implements Tapper { + SINGLE { + @Override + public Tapper.Status sendTap(UiController uiController, float[] coordinates, + float[] precision) { + checkNotNull(uiController); + + checkNotNull(coordinates); + checkNotNull(precision); + DownResultHolder res = MotionEvents.sendDown(uiController, coordinates, precision); + try { + if (!MotionEvents.sendUp(uiController, res.down)) { + Log.d(TAG, "Injection of up event as part of the click failed. Send cancel event."); + MotionEvents.sendCancel(uiController, res.down); + return Tapper.Status.FAILURE; + } + } finally { + res.down.recycle(); + } + return res.longPress ? Tapper.Status.WARNING : Tapper.Status.SUCCESS; + } + }, + LONG { + @Override + public Tapper.Status sendTap(UiController uiController, float[] coordinates, + float[] precision) { + checkNotNull(uiController); + checkNotNull(coordinates); + checkNotNull(precision); + + MotionEvent downEvent = MotionEvents.sendDown(uiController, coordinates, precision).down; + try { + // Duration before a press turns into a long press. + // Factor 1.5 is needed, otherwise a long press is not safely detected. + // See android.test.TouchUtils longClickView + long longPressTimeout = (long) (ViewConfiguration.getLongPressTimeout() * 1.5f); + uiController.loopMainThreadForAtLeast(longPressTimeout); + + if (!MotionEvents.sendUp(uiController, downEvent)) { + MotionEvents.sendCancel(uiController, downEvent); + return Tapper.Status.FAILURE; + } + } finally { + downEvent.recycle(); + downEvent = null; + } + return Tapper.Status.SUCCESS; + } + }, + DOUBLE { + @Override + public Tapper.Status sendTap(UiController uiController, float[] coordinates, + float[] precision) { + checkNotNull(uiController); + checkNotNull(coordinates); + checkNotNull(precision); + Tapper.Status stat = SINGLE.sendTap(uiController, coordinates, precision); + if (stat == Tapper.Status.FAILURE) { + return Tapper.Status.FAILURE; + } + + Tapper.Status secondStat = SINGLE.sendTap(uiController, coordinates, precision); + + if (secondStat == Tapper.Status.FAILURE) { + return Tapper.Status.FAILURE; + } + + if (secondStat == Tapper.Status.WARNING || stat == Tapper.Status.WARNING) { + return Tapper.Status.WARNING; + } else { + return Tapper.Status.SUCCESS; + } + } + }; + + private static final String TAG = Tap.class.getSimpleName(); + +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/Tapper.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/Tapper.java new file mode 100644 index 0000000..8a57c53 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/Tapper.java @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.action; + +import com.google.android.apps.common.testing.ui.espresso.UiController; + +/** + * Interface to implement different click types. + */ +public interface Tapper { + + /** + * The result of the tap. + */ + public enum Status { + /** + * The Tap action completed successfully. + */ + SUCCESS, + /** + * The action seemed to have completed - but may have been misinterpreted + * by the application. (For Example a TAP became a LONG PRESS by measuring + * its time between the down and up events). + */ + WARNING, + /** + * Injecting the event was a complete failure. + */ + FAILURE } + + /** + * Sends a MotionEvent to the given UiController. + * + * @param uiController a UiController to use to send MotionEvents to the screen. + * @param coordinates a float[] with x and y values of center of the tap. + * @param precision a float[] with x and y values of precision of the tap. + * @return The status of the tap. + */ + public Status sendTap(UiController uiController, float[] coordinates, float[] precision); +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/TypeTextAction.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/TypeTextAction.java new file mode 100644 index 0000000..81d388b --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/TypeTextAction.java @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.hasFocus; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isAssignableFrom; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDisplayed; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.supportsInputMethods; +import static com.google.common.base.Preconditions.checkNotNull; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.anyOf; + +import com.google.android.apps.common.testing.ui.espresso.InjectEventSecurityException; +import com.google.android.apps.common.testing.ui.espresso.PerformException; +import com.google.android.apps.common.testing.ui.espresso.UiController; +import com.google.android.apps.common.testing.ui.espresso.ViewAction; +import com.google.android.apps.common.testing.ui.espresso.util.HumanReadables; + +import android.os.Build; +import android.util.Log; +import android.view.View; +import android.widget.SearchView; + +import org.hamcrest.Matcher; + +/** + * Enables typing text on views. + */ +public final class TypeTextAction implements ViewAction { + private static final String TAG = TypeTextAction.class.getSimpleName(); + private final String stringToBeTyped; + private final boolean tapToFocus; + + /** + * Constructs {@link TypeTextAction} with given string. If the string is empty it results in no-op + * (nothing is typed). By default this action sends a tap event to the center of the view to + * attain focus before typing. + * + * @param stringToBeTyped String To be typed by {@link TypeTextAction} + */ + public TypeTextAction(String stringToBeTyped) { + this(stringToBeTyped, true); + } + + /** + * Constructs {@link TypeTextAction} with given string. If the string is empty it results in no-op + * (nothing is typed). + * + * @param stringToBeTyped String To be typed by {@link TypeTextAction} + * @param tapToFocus indicates whether a tap should be sent to the underlying view before typing. + */ + public TypeTextAction(String stringToBeTyped, boolean tapToFocus) { + checkNotNull(stringToBeTyped); + this.stringToBeTyped = stringToBeTyped; + this.tapToFocus = tapToFocus; + } + + @SuppressWarnings("unchecked") + @Override + public Matcher<View> getConstraints() { + Matcher<View> matchers = allOf(isDisplayed()); + if (!tapToFocus) { + matchers = allOf(matchers, hasFocus()); + } + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.HONEYCOMB) { + return allOf(matchers, supportsInputMethods()); + } else { + // SearchView does not support input methods itself (rather it delegates to an internal text + // view for input). + return allOf(matchers, anyOf(supportsInputMethods(), isAssignableFrom(SearchView.class))); + } + } + + @Override + public void perform(UiController uiController, View view) { + // No-op if string is empty. + if (stringToBeTyped.length() == 0) { + Log.w(TAG, "Supplied string is empty resulting in no-op (nothing is typed)."); + return; + } + + if (tapToFocus) { + // Perform a click. + new GeneralClickAction(Tap.SINGLE, GeneralLocation.CENTER, Press.FINGER) + .perform(uiController, view); + uiController.loopMainThreadUntilIdle(); + } + + try { + if (!uiController.injectString(stringToBeTyped)) { + Log.e(TAG, "Failed to type text: " + stringToBeTyped); + throw new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(new RuntimeException("Failed to type text: " + stringToBeTyped)) + .build(); + } + } catch (InjectEventSecurityException e) { + Log.e(TAG, "Failed to type text: " + stringToBeTyped); + throw new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(e) + .build(); + } + } + + @Override + public String getDescription() { + return "type text"; + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/ViewActions.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/ViewActions.java new file mode 100644 index 0000000..07b0e3d --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/ViewActions.java @@ -0,0 +1,223 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.android.apps.common.testing.ui.espresso.ViewAction; + +import android.view.KeyEvent; + +/** + * A collection of common {@link ViewActions}. + */ +public final class ViewActions { + + private ViewActions() {} + + /** + * Returns an action that clears text on the view.<br> + * <br> + * View constraints: + * <ul> + * <li>must be displayed on screen + * <ul> + */ + public static ViewAction clearText() { + return new ClearTextAction(); + } + + /** + * Returns an action that clicks the view.<br> + * <br> + * View constraints: + * <ul> + * <li>must be displayed on screen + * <ul> + */ + public static ViewAction click() { + return new GeneralClickAction(Tap.SINGLE, GeneralLocation.CENTER, Press.FINGER); + } + + /** + * Returns an action that performs a single click on the view. + * + * If the click takes longer than the 'long press' duration (which is possible) the provided + * rollback action is invoked on the view and a click is attempted again. + * + * This is only necessary if the view being clicked on has some different behaviour for long press + * versus a normal tap. + * + * For example - if a long press on a particular view element opens a popup menu - + * ViewActions.pressBack() may be an acceptable rollback action. + * + * <br> + * View constraints: + * <ul> + * <li>must be displayed on screen</li> + * <li>any constraints of the rollbackAction</li> + * <ul> + */ + public static ViewAction click(ViewAction rollbackAction) { + checkNotNull(rollbackAction); + return new GeneralClickAction(Tap.SINGLE, GeneralLocation.CENTER, Press.FINGER, + rollbackAction); + } + + /** + * Returns an action that performs a swipe right-to-left across the vertical center of the + * view.<br> + * <br> + * View constraints: + * <ul> + * <li>must be displayed on screen + * <ul> + */ + public static ViewAction swipeLeft() { + return new GeneralSwipeAction(Swipe.FAST, GeneralLocation.CENTER_RIGHT, + GeneralLocation.CENTER_LEFT, Press.FINGER); + } + + /** + * Returns an action that performs a swipe left-to-right across the vertical center of the + * view.<br> + * <br> + * View constraints: + * <ul> + * <li>must be displayed on screen + * <ul> + */ + public static ViewAction swipeRight() { + return new GeneralSwipeAction(Swipe.FAST, GeneralLocation.CENTER_LEFT, + GeneralLocation.CENTER_RIGHT, Press.FINGER); + } + + /** + * Returns an action that closes soft keyboard. If the keyboard is already closed, it is a no-op. + */ + public static ViewAction closeSoftKeyboard() { + return new CloseKeyboardAction(); + } + + /** + * Returns an action that presses the current action button (next, done, search, etc) on the IME + * (Input Method Editor). The selected view will have its onEditorAction method called. + */ + public static ViewAction pressImeActionButton() { + return new EditorAction(); + } + + /** + * Returns an action that clicks the back button. + */ + public static ViewAction pressBack() { + return pressKey(KeyEvent.KEYCODE_BACK); + } + + /** + * Returns an action that presses the hardware menu key. + */ + public static ViewAction pressMenuKey() { + return pressKey(KeyEvent.KEYCODE_MENU); + } + + /** + * Returns an action that presses the key specified by the keyCode (eg. Keyevent.KEYCODE_BACK). + */ + public static ViewAction pressKey(int keyCode) { + return new KeyEventAction(new EspressoKey.Builder().withKeyCode(keyCode).build()); + } + + /** + * Returns an action that presses the specified key with the specified modifiers. + */ + public static ViewAction pressKey(EspressoKey key) { + return new KeyEventAction(key); + } + + /** + * Returns an action that double clicks the view.<br> + * <br> + * View preconditions: + * <ul> + * <li>must be displayed on screen + * <ul> + */ + public static ViewAction doubleClick() { + return new GeneralClickAction(Tap.DOUBLE, GeneralLocation.CENTER, Press.FINGER); + } + + /** + * Returns an action that long clicks the view.<br> + * + * <br> + * View preconditions: + * <ul> + * <li>must be displayed on screen + * <ul> + */ + public static ViewAction longClick() { + return new GeneralClickAction(Tap.LONG, GeneralLocation.CENTER, Press.FINGER); + } + + /** + * Returns an action that scrolls to the view.<br> + * <br> + * View preconditions: + * <ul> + * <li>must be a descendant of ScrollView + * <li>must have visibility set to View.VISIBLE + * <ul> + */ + public static ViewAction scrollTo() { + return new ScrollToAction(); + } + + /** + * Returns an action that types the provided string into the view. + * Appending a \n to the end of the string translates to a ENTER key event. Note: this method + * does not change cursor position in the focused view - text is inserted at the location where + * the cursor is currently pointed.<br> + * <br> + * View preconditions: + * <ul> + * <li>must be displayed on screen + * <li>must support input methods + * <li>must be already focused + * <ul> + */ + public static ViewAction typeTextIntoFocusedView(String stringToBeTyped) { + return new TypeTextAction(stringToBeTyped, false /* tapToFocus */); + } + + /** + * Returns an action that selects the view (by clicking on it) and types the provided string into + * the view. Appending a \n to the end of the string translates to a ENTER key event. Note: this + * method performs a tap on the view before typing to force the view into focus, if the view + * already contains text this tap may place the cursor at an arbitrary position within the text. + * <br> + * <br> + * View preconditions: + * <ul> + * <li>must be displayed on screen + * <li>must support input methods + * <ul> + */ + public static ViewAction typeText(String stringToBeTyped) { + return new TypeTextAction(stringToBeTyped); + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/assertion/ViewAssertions.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/assertion/ViewAssertions.java new file mode 100644 index 0000000..eb14861 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/assertion/ViewAssertions.java @@ -0,0 +1,142 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.assertion; + +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.assertThat; +import static com.google.android.apps.common.testing.ui.espresso.util.TreeIterables.breadthFirstViewTraversal; +import static com.google.common.base.Preconditions.checkNotNull; +import static org.hamcrest.Matchers.is; + +import com.google.android.apps.common.testing.ui.espresso.NoMatchingViewException; +import com.google.android.apps.common.testing.ui.espresso.ViewAssertion; +import com.google.android.apps.common.testing.ui.espresso.util.HumanReadables; +import com.google.common.base.Preconditions; +import com.google.common.base.Predicate; +import com.google.common.collect.Iterables; + +import android.util.Log; +import android.view.View; + +import junit.framework.AssertionFailedError; + +import org.hamcrest.Matcher; +import org.hamcrest.StringDescription; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +/** + * A collection of common {@link ViewAssertion}s. + */ +public final class ViewAssertions { + + private static final String TAG = ViewAssertions.class.getSimpleName(); + + + private ViewAssertions() {} + + /** + * Returns an assert that ensures the view matcher does not find any matching view in the + * hierarchy. + */ + public static ViewAssertion doesNotExist() { + return new ViewAssertion() { + @Override + public void check(View view, NoMatchingViewException noView) { + if (view != null) { + assertThat("View is present in the hierarchy: " + HumanReadables.describe(view), true, + is(false)); + } + } + }; + } + + /** + * Returns a generic {@link ViewAssertion} that asserts that a view exists in the view hierarchy + * and is matched by the given view matcher. + */ + public static ViewAssertion matches(final Matcher<? super View> viewMatcher) { + checkNotNull(viewMatcher); + return new ViewAssertion() { + @Override + public void check(View view, NoMatchingViewException noViewException) { + StringDescription description = new StringDescription(); + description.appendText("'"); + viewMatcher.describeTo(description); + if (noViewException != null) { + description.appendText(String.format( + "' check could not be performed because view '%s' was not found.\n", viewMatcher)); + Log.e(TAG, description.toString()); + throw noViewException; + } else { + // TODO(valeraz): ideally, we should append the matcher used to find the view + // This can be done in DefaultFailureHandler (just like we currently to with + // PerformException) + description.appendText("' doesn't match the selected view."); + assertThat(description.toString(), view, viewMatcher); + } + } + }; + } + + + /** + * Returns a generic {@link ViewAssertion} that asserts that the descendant views selected by the + * selector match the specified matcher. + * + * Example: onView(rootView).check(selectedDescendantsMatch( + * not(isAssignableFrom(TextView.class)), hasContentDescription())); + */ + public static ViewAssertion selectedDescendantsMatch( + final Matcher<View> selector, final Matcher<View> matcher) { + return new ViewAssertion() { + @SuppressWarnings("unchecked") + @Override + public void check(View view, NoMatchingViewException noViewException) { + Preconditions.checkNotNull(view); + + final Predicate<View> viewPredicate = new Predicate<View>() { + @Override + public boolean apply(View input) { + return selector.matches(input); + } + }; + + Iterator<View> selectedViewIterator = + Iterables.filter(breadthFirstViewTraversal(view), viewPredicate).iterator(); + + List<View> nonMatchingViews = new ArrayList<View>(); + while (selectedViewIterator.hasNext()) { + View selectedView = selectedViewIterator.next(); + + if (!matcher.matches(selectedView)) { + nonMatchingViews.add(selectedView); + } + } + + if (nonMatchingViews.size() > 0) { + String errorMessage = HumanReadables.getViewHierarchyErrorMessage(view, + nonMatchingViews, + String.format("At least one view did not match the required matcher: %s", matcher), + "****DOES NOT MATCH****"); + throw new AssertionFailedError(errorMessage); + } + } + }; + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/AsyncTaskPoolMonitor.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/AsyncTaskPoolMonitor.java new file mode 100644 index 0000000..082c045 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/AsyncTaskPoolMonitor.java @@ -0,0 +1,208 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.base; + +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; + +import java.util.concurrent.BrokenBarrierException; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Provides a way to monitor AsyncTask's work queue to ensure that there is no work pending + * or executing (and to allow notification of idleness). + * + * This class is based on the assumption that we can get at the ThreadPoolExecutor AsyncTask uses. + * That is currently possible and easy in Froyo to JB. If it ever becomes impossible, as long as we + * know the max # of executor threads the AsyncTask framework allows we can still use this + * interface, just need a different implementation. + */ +class AsyncTaskPoolMonitor { + private final AtomicReference<IdleMonitor> monitor = new AtomicReference<IdleMonitor>(null); + private final ThreadPoolExecutor pool; + private final AtomicInteger activeBarrierChecks = new AtomicInteger(0); + + AsyncTaskPoolMonitor(ThreadPoolExecutor pool) { + this.pool = checkNotNull(pool); + } + + /** + * Checks if the pool is idle at this moment. + * + * @return true if the pool is idle, false otherwise. + */ + boolean isIdleNow() { + if (!pool.getQueue().isEmpty()) { + return false; + } else { + int activeCount = pool.getActiveCount(); + if (0 != activeCount) { + if (monitor.get() == null) { + // if there's no idle monitor scheduled and there are still barrier + // checks running, they are about to exit, ignore them. + activeCount = activeCount - activeBarrierChecks.get(); + } + } + return 0 == activeCount; + } + } + + /** + * Notifies caller once the pool is idle. + * + * We check for idle-ness by submitting the max # of tasks the pool will take and blocking + * the tasks until they are all executing. Then we know there are no other tasks _currently_ + * executing in the pool, we look back at the work queue to see if its backed up, if it is + * we reenqueue ourselves and try again. + * + * Obviously this strategy will fail horribly if 2 parties are doing it at the same time, + * we prevent recursion here the best we can. + * + * @param idleCallback called once the pool is idle. + */ + void notifyWhenIdle(final Runnable idleCallback) { + checkNotNull(idleCallback); + IdleMonitor myMonitor = new IdleMonitor(idleCallback); + checkState(monitor.compareAndSet(null, myMonitor), "cannot monitor for idle recursively!"); + myMonitor.monitorForIdle(); + } + + /** + * Stops the idle monitoring mechanism if it is in place. + * + * Note: the callback may still be invoked after this method is called. The only thing + * this method guarantees is that we will stop/cancel any blockign tasks we've placed + * on the thread pool. + */ + void cancelIdleMonitor() { + IdleMonitor myMonitor = monitor.getAndSet(null); + if (null != myMonitor) { + myMonitor.poison(); + } + } + + private class IdleMonitor { + private final Runnable onIdle; + private final AtomicInteger barrierGeneration = new AtomicInteger(0); + private final CyclicBarrier barrier; + // written by main, read by all. + private volatile boolean poisoned; + + private IdleMonitor(final Runnable onIdle) { + this.onIdle = checkNotNull(onIdle); + this.barrier = new CyclicBarrier(pool.getCorePoolSize(), + new Runnable() { + @Override + public void run() { + if (pool.getQueue().isEmpty()) { + // no one is behind us, so the queue is idle! + monitor.compareAndSet(IdleMonitor.this, null); + onIdle.run(); + } else { + // work is waiting behind us, enqueue another block of tasks and + // hopefully when they're all running, the queue will be empty. + monitorForIdle(); + } + + } + }); + } + + /** + * Stops this monitor from using the thread pool's resources, it may still cause the + * callback to be executed though. + */ + private void poison() { + poisoned = true; + barrier.reset(); + } + + private void monitorForIdle() { + if (poisoned) { + return; + } + + if (isIdleNow()) { + monitor.compareAndSet(this, null); + onIdle.run(); + } else { + // Submit N tasks that will block until they are all running on the thread pool. + // at this point we can check the pool's queue and verify that there are no new + // tasks behind us and deem the queue idle. + + int poolSize = pool.getCorePoolSize(); + final BarrierRestarter restarter = new BarrierRestarter(barrier, barrierGeneration); + + for (int i = 0; i < poolSize; i++) { + pool.execute(new Runnable() { + @Override + public void run() { + while (!poisoned) { + activeBarrierChecks.incrementAndGet(); + int myGeneration = barrierGeneration.get(); + try { + barrier.await(); + return; + } catch (InterruptedException ie) { + // sorry - I cant let you interrupt me! + restarter.restart(myGeneration); + } catch (BrokenBarrierException bbe) { + restarter.restart(myGeneration); + } finally { + activeBarrierChecks.decrementAndGet(); + } + } + } + }); + } + } + } + } + + + private static class BarrierRestarter { + private final CyclicBarrier barrier; + private final AtomicInteger barrierGeneration; + BarrierRestarter(CyclicBarrier barrier, AtomicInteger barrierGeneration) { + this.barrier = barrier; + this.barrierGeneration = barrierGeneration; + } + + /** + * restarts the barrier. + * + * After the calling this function it is guaranteed that barrier generation has been incremented + * and the barrier can be awaited on again. + * + * @param fromGeneration the generation that encountered the breaking exception. + */ + synchronized void restart(int fromGeneration) { + // must be synchronized. T1 could pass the if check, be suspended before calling reset, T2 + // sails thru - and awaits on the barrier again before T1 has awoken and reset it. + int nextGen = fromGeneration + 1; + if (barrierGeneration.compareAndSet(fromGeneration, nextGen)) { + // first time we've seen fromGeneration request a reset. lets reset the barrier. + barrier.reset(); + } else { + // some other thread has already reset the barrier - this request is a no op. + } + } + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/BaseLayerModule.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/BaseLayerModule.java new file mode 100644 index 0000000..6615b1d --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/BaseLayerModule.java @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.base; + +import com.google.android.apps.common.testing.testrunner.ActivityLifecycleMonitor; +import com.google.android.apps.common.testing.testrunner.ActivityLifecycleMonitorRegistry; +import com.google.android.apps.common.testing.testrunner.InstrumentationRegistry; +import com.google.android.apps.common.testing.testrunner.inject.TargetContext; +import com.google.android.apps.common.testing.ui.espresso.FailureHandler; +import com.google.android.apps.common.testing.ui.espresso.Root; +import com.google.android.apps.common.testing.ui.espresso.UiController; +import com.google.common.base.Optional; + +import android.content.Context; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; + +import dagger.Module; +import dagger.Provides; + +import java.util.List; +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.atomic.AtomicReference; + +import javax.inject.Inject; +import javax.inject.Singleton; + +/** + * Dagger module for creating the implementation classes within the base package. + */ +@Module(library = true, injects = { + BaseLayerModule.FailureHandlerHolder.class, FailureHandler.class}) +public class BaseLayerModule { + + @Provides @Singleton + public ActivityLifecycleMonitor provideLifecycleMonitor() { + // TODO(user): replace with installation of AndroidInstrumentationModule once + // proguard issues resolved. + return ActivityLifecycleMonitorRegistry.getInstance(); + } + + @Provides @TargetContext + public Context provideTargetContext() { + // TODO(user): replace with installation of AndroidInstrumentationModule once + // proguard issues resolved. + return InstrumentationRegistry.getInstance().getTargetContext(); + } + + @Provides @Singleton + public Looper provideMainLooper() { + return Looper.getMainLooper(); + } + + @Provides + public UiController provideUiController(UiControllerImpl uiControllerImpl) { + return uiControllerImpl; + } + + @Provides @Singleton @CompatAsyncTask + public Optional<AsyncTaskPoolMonitor> provideCompatAsyncTaskMonitor( + ThreadPoolExecutorExtractor extractor) { + Optional<ThreadPoolExecutor> compatThreadPool = extractor.getCompatAsyncTaskThreadPool(); + if (compatThreadPool.isPresent()) { + return Optional.of(new AsyncTaskPoolMonitor(compatThreadPool.get())); + } else { + return Optional.<AsyncTaskPoolMonitor>absent(); + } + } + + @Provides @Singleton @MainThread + public Executor provideMainThreadExecutor(Looper mainLooper) { + final Handler handler = new Handler(mainLooper); + return new Executor() { + @Override + public void execute(Runnable runnable) { + handler.post(runnable); + } + }; + } + + @Provides @Singleton @SdkAsyncTask + public AsyncTaskPoolMonitor provideSdkAsyncTaskMonitor(ThreadPoolExecutorExtractor extractor) { + return new AsyncTaskPoolMonitor(extractor.getAsyncTaskThreadPool()); + + } + + @Provides + public List<Root> provideKnownRoots(RootsOracle rootsOracle) { + // RootsOracle acts as a provider, but returning Providers is illegal, so delegate. + return rootsOracle.get(); + } + + @Provides @Singleton + public EventInjector provideEventInjector() { + // On API 16 and above, android uses input manager to inject events. On API < 16, + // they use Window Manager. So we need to create our InjectionStrategy depending on the api + // level. Instrumentation does not check if the event presses went through by checking the + // boolean return value of injectInputEvent, which is why we created this class to better + // handle lost/dropped press events. Instrumentation cannot be used as a fallback strategy, + // since this will be executed on the main thread. + int sdkVersion = Build.VERSION.SDK_INT; + EventInjectionStrategy injectionStrategy = null; + if (sdkVersion >= 16) { // Use InputManager for API level 16 and up. + InputManagerEventInjectionStrategy strategy = new InputManagerEventInjectionStrategy(); + strategy.initialize(); + injectionStrategy = strategy; + } else if (sdkVersion >= 7) { + // else Use WindowManager for API level 15 through 7. + WindowManagerEventInjectionStrategy strategy = new WindowManagerEventInjectionStrategy(); + strategy.initialize(); + injectionStrategy = strategy; + } else { + throw new RuntimeException( + "API Level 6 and below is not supported. You are running: " + sdkVersion); + } + return new EventInjector(injectionStrategy); + } + + /** + * Holder for AtomicReference<FailureHandler> which allows updating it at runtime. + */ + @Singleton + public static class FailureHandlerHolder { + private final AtomicReference<FailureHandler> holder; + + @Inject + public FailureHandlerHolder(@Default FailureHandler defaultHandler) { + holder = new AtomicReference<FailureHandler>(defaultHandler); + } + + public void update(FailureHandler handler) { + holder.set(handler); + } + + public FailureHandler get() { + return holder.get(); + } + } + + @Provides + FailureHandler provideFailureHandler(FailureHandlerHolder holder) { + return holder.get(); + } + + @Provides + @Default + FailureHandler provideFailureHander(DefaultFailureHandler impl) { + return impl; + } + +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/CompatAsyncTask.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/CompatAsyncTask.java new file mode 100644 index 0000000..11ec6ab --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/CompatAsyncTask.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.base; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +import javax.inject.Qualifier; + +/** + * Annotates a AsyncTaskMonitor as monitoring the CompatAsyncTask pool + */ +@Qualifier +@Retention(RetentionPolicy.RUNTIME) +@interface CompatAsyncTask { } diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/Default.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/Default.java new file mode 100644 index 0000000..b54440d --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/Default.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.base; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +import javax.inject.Qualifier; + +/** + * Annotates a default provider. + */ +@Qualifier +@Retention(RetentionPolicy.RUNTIME) +public @interface Default { +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/DefaultFailureHandler.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/DefaultFailureHandler.java new file mode 100644 index 0000000..b1e43da --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/DefaultFailureHandler.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.base; + +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Throwables.propagate; + +import com.google.android.apps.common.testing.testrunner.inject.TargetContext; +import com.google.android.apps.common.testing.ui.espresso.EspressoException; +import com.google.android.apps.common.testing.ui.espresso.FailureHandler; +import com.google.android.apps.common.testing.ui.espresso.PerformException; + +import android.content.Context; +import android.view.View; + +import junit.framework.AssertionFailedError; + +import org.hamcrest.Matcher; + +import java.util.concurrent.atomic.AtomicInteger; + +import javax.inject.Inject; + +/** + * Espresso's default {@link FailureHandler}. If this does not fit your needs, feel free to provide + * your own implementation via Espresso.setFailureHandler(FailureHandler). + */ +public final class DefaultFailureHandler implements FailureHandler { + + private static final AtomicInteger failureCount = new AtomicInteger(0); + private final Context appContext; + + @Inject + public DefaultFailureHandler(@TargetContext Context appContext) { + this.appContext = checkNotNull(appContext); + } + + @Override + public void handle(Throwable error, Matcher<View> viewMatcher) { + if (error instanceof EspressoException || error instanceof AssertionFailedError + || error instanceof AssertionError) { + throw propagate(getUserFriendlyError(error, viewMatcher)); + } else { + throw propagate(error); + } + } + + /** + * When the error is coming from espresso, it is more user friendly to: + * 1. propagate assertions as assertions + * 2. swap the stack trace of the error to that of current thread (which will show + * directly where the actual problem is) + */ + private Throwable getUserFriendlyError(Throwable error, Matcher<View> viewMatcher) { + if (error instanceof PerformException) { + // Re-throw the exception with the viewMatcher (used to locate the view) as the view + // description (makes the error more readable). The reason we do this here: not all creators + // of PerformException have access to the viewMatcher. + throw new PerformException.Builder() + .from((PerformException) error) + .withViewDescription(viewMatcher.toString()) + .build(); + } + + if (error instanceof AssertionError) { + // reports Failure instead of Error. + // assertThat(...) throws an AssertionFailedError. + error = new AssertionFailedWithCauseError(error.getMessage(), error); + } + + error.setStackTrace(Thread.currentThread().getStackTrace()); + return error; + } + + private static final class AssertionFailedWithCauseError extends AssertionFailedError { + /* junit hides the cause constructor. */ + public AssertionFailedWithCauseError(String message, Throwable cause) { + super(message); + initCause(cause); + } + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/EventInjectionStrategy.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/EventInjectionStrategy.java new file mode 100644 index 0000000..3378197 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/EventInjectionStrategy.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.base; + +import com.google.android.apps.common.testing.ui.espresso.InjectEventSecurityException; + +import android.view.KeyEvent; +import android.view.MotionEvent; + +/** + * Injects Events into the application under test. Implementors should expect to be called + * from the UI thread and are responsible for ensuring the event gets delivered or indicating that + * it could not be delivered. + */ +interface EventInjectionStrategy { + /** + * Injects the given {@link KeyEvent} into the android system. + * + * @param keyEvent The event to inject + * @return {@code true} if the input was inject successfully, {@code false} otherwise. + * @throws InjectEventSecurityException if the MotionEvent would be delivered to an area of the + * screen that is not owned by the application under test. + */ + boolean injectKeyEvent(KeyEvent keyEvent) throws InjectEventSecurityException; + + /** + * Injects the given {@link MotionEvent} into the android system. + * + * @param motionEvent The event to inject + * @return {@code true} if the input was inject successfully, {@code false} otherwise. + * @throws InjectEventSecurityException if the MotionEvent would be delivered to an area of the + * screen that is not owned by the application under test. + */ + boolean injectMotionEvent(MotionEvent motionEvent) throws InjectEventSecurityException; + +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/EventInjector.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/EventInjector.java new file mode 100644 index 0000000..0728331 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/EventInjector.java @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.base; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.android.apps.common.testing.ui.espresso.InjectEventSecurityException; + +import android.os.Build; +import android.os.SystemClock; +import android.util.Log; +import android.view.KeyEvent; +import android.view.MotionEvent; + +/** + * Responsible for selecting the proper strategy for injecting MotionEvents to the application under + * test. + */ +final class EventInjector { + private static final String TAG = EventInjector.class.getSimpleName(); + private final EventInjectionStrategy injectionStrategy; + + EventInjector(EventInjectionStrategy injectionStrategy) { + this.injectionStrategy = checkNotNull(injectionStrategy); + } + + boolean injectKeyEvent(KeyEvent event) throws InjectEventSecurityException { + long downTime = event.getDownTime(); + long eventTime = event.getEventTime(); + int action = event.getAction(); + int code = event.getKeyCode(); + int repeatCount = event.getRepeatCount(); + int metaState = event.getMetaState(); + int deviceId = event.getDeviceId(); + int scancode = event.getScanCode(); + int flags = event.getFlags(); + + if (eventTime == 0) { + eventTime = SystemClock.uptimeMillis(); + } + + if (downTime == 0) { + downTime = eventTime; + } + + // API < 9 does not have constructor with source (nor has source field). + KeyEvent newEvent; + if (Build.VERSION.SDK_INT < 9) { + newEvent = new KeyEvent(downTime, + eventTime, + action, + code, + repeatCount, + metaState, + deviceId, + scancode, + flags | KeyEvent.FLAG_FROM_SYSTEM); + } else { + int source = event.getSource(); + newEvent = new KeyEvent(downTime, + eventTime, + action, + code, + repeatCount, + metaState, + deviceId, + scancode, + flags | KeyEvent.FLAG_FROM_SYSTEM, + source); + } + + Log.v( + "ESP_TRACE", + String.format( + "%s:Injecting event for character (%c) with key code (%s) downtime: (%s)", TAG, + newEvent.getUnicodeChar(), newEvent.getKeyCode(), newEvent.getDownTime())); + + return injectionStrategy.injectKeyEvent(newEvent); + } + + boolean injectMotionEvent(MotionEvent event) throws InjectEventSecurityException { + return injectionStrategy.injectMotionEvent(event); + } + +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/IdlingResourceRegistry.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/IdlingResourceRegistry.java new file mode 100644 index 0000000..e390f0f --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/IdlingResourceRegistry.java @@ -0,0 +1,289 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.base; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; + +import com.google.android.apps.common.testing.ui.espresso.IdlingPolicies; +import com.google.android.apps.common.testing.ui.espresso.IdlingPolicy; +import com.google.android.apps.common.testing.ui.espresso.IdlingResource; +import com.google.android.apps.common.testing.ui.espresso.IdlingResource.ResourceCallback; +import com.google.common.collect.Lists; + +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.util.Log; + +import java.util.BitSet; +import java.util.List; + +import javax.inject.Inject; +import javax.inject.Singleton; + +/** + * Keeps track of user-registered {@link IdlingResource}s. + */ +@Singleton +public final class IdlingResourceRegistry { + private static final String TAG = IdlingResourceRegistry.class.getSimpleName(); + + private static final int DYNAMIC_RESOURCE_HAS_IDLED = 1; + private static final int TIMEOUT_OCCURRED = 2; + private static final int IDLE_WARNING_REACHED = 3; + private static final int POSSIBLE_RACE_CONDITION_DETECTED = 4; + private static final Object TIMEOUT_MESSAGE_TAG = new Object(); + + private static final IdleNotificationCallback NO_OP_CALLBACK = new IdleNotificationCallback() { + + @Override + public void allResourcesIdle() {} + + @Override + public void resourcesStillBusyWarning(List<String> busys) {} + + @Override + public void resourcesHaveTimedOut(List<String> busys) {} + }; + + // resources and idleState should only be accessed on main thread + private final List<IdlingResource> resources = Lists.newArrayList(); + // idleState.get(i) == true indicates resources.get(i) is idle, false indicates it's busy + private final BitSet idleState = new BitSet(); + private final Looper looper; + private final Handler handler; + private final Dispatcher dispatcher; + private IdleNotificationCallback idleNotificationCallback = NO_OP_CALLBACK; + + @Inject + public IdlingResourceRegistry(Looper looper) { + this.looper = looper; + this.dispatcher = new Dispatcher(); + this.handler = new Handler(looper, dispatcher); + } + + /** + * Registers the given resource. + */ + public void register(final IdlingResource resource) { + checkNotNull(resource); + if (Looper.myLooper() != looper) { + handler.post(new Runnable() { + @Override + public void run() { + register(resource); + } + }); + } else { + for (IdlingResource oldResource : resources) { + if (resource.getName().equals(oldResource.getName())) { + // This does not throw an error to avoid leaving tests that register resource in test + // setup in an undeterministic state (we cannot assume that everyone clears vm state + // between each test run) + Log.e(TAG, String.format("Attempted to register resource with same names:" + + " %s. R1: %s R2: %s.\nDuplicate resource registration will be ignored.", + resource.getName(), resource, oldResource)); + return; + } + } + resources.add(resource); + final int position = resources.size() - 1; + registerToIdleCallback(resource, position); + idleState.set(position, resource.isIdleNow()); + } + } + + public void registerLooper(Looper looper, boolean considerWaitIdle) { + checkNotNull(looper); + checkArgument(Looper.getMainLooper() != looper, "Not intended for use with main looper!"); + register(new LooperIdlingResource(looper, considerWaitIdle)); + } + + private void registerToIdleCallback(IdlingResource resource, final int position) { + resource.registerIdleTransitionCallback(new ResourceCallback() { + @Override + public void onTransitionToIdle() { + Message m = handler.obtainMessage(DYNAMIC_RESOURCE_HAS_IDLED); + m.arg1 = position; + handler.sendMessage(m); + } + }); + } + + boolean allResourcesAreIdle() { + checkState(Looper.myLooper() == looper); + for (int i = idleState.nextSetBit(0); i >= 0 && i < resources.size(); + i = idleState.nextSetBit(i + 1)) { + idleState.set(i, resources.get(i).isIdleNow()); + } + return idleState.cardinality() == resources.size(); + } + + interface IdleNotificationCallback { + public void allResourcesIdle(); + + public void resourcesStillBusyWarning(List<String> busyResourceNames); + + public void resourcesHaveTimedOut(List<String> busyResourceNames); + } + + void notifyWhenAllResourcesAreIdle(IdleNotificationCallback callback) { + checkNotNull(callback); + checkState(Looper.myLooper() == looper); + checkState(idleNotificationCallback == NO_OP_CALLBACK, "Callback has already been registered."); + if (allResourcesAreIdle()) { + callback.allResourcesIdle(); + } else { + idleNotificationCallback = callback; + scheduleTimeoutMessages(); + } + } + + void cancelIdleMonitor() { + dispatcher.deregister(); + } + + private void scheduleTimeoutMessages() { + IdlingPolicy warning = IdlingPolicies.getDynamicIdlingResourceWarningPolicy(); + Message timeoutWarning = handler.obtainMessage(IDLE_WARNING_REACHED, TIMEOUT_MESSAGE_TAG); + handler.sendMessageDelayed(timeoutWarning, warning.getIdleTimeoutUnit().toMillis( + warning.getIdleTimeout())); + Message timeoutError = handler.obtainMessage(TIMEOUT_OCCURRED, TIMEOUT_MESSAGE_TAG); + IdlingPolicy error = IdlingPolicies.getDynamicIdlingResourceErrorPolicy(); + + handler.sendMessageDelayed(timeoutError, error.getIdleTimeoutUnit().toMillis( + error.getIdleTimeout())); + } + + private List<String> getBusyResources() { + List<String> busyResourceNames = Lists.newArrayList(); + List<Integer> racyResources = Lists.newArrayList(); + + for (int i = 0; i < resources.size(); i++) { + IdlingResource resource = resources.get(i); + if (!idleState.get(i)) { + if (resource.isIdleNow()) { + // We have not been notified of a BUSY -> IDLE transition, but the resource is telling us + // its that its idle. Either it's a race condition or is this resource buggy. + racyResources.add(i); + } else { + busyResourceNames.add(resource.getName()); + } + } + } + + if (!racyResources.isEmpty()) { + Message raceBuster = handler.obtainMessage(POSSIBLE_RACE_CONDITION_DETECTED, + TIMEOUT_MESSAGE_TAG); + raceBuster.obj = racyResources; + handler.sendMessage(raceBuster); + return null; + } else { + return busyResourceNames; + } + } + + + private class Dispatcher implements Handler.Callback { + @Override + public boolean handleMessage(Message m) { + switch (m.what) { + case DYNAMIC_RESOURCE_HAS_IDLED: + handleResourceIdled(m); + break; + case IDLE_WARNING_REACHED: + handleTimeoutWarning(); + break; + case TIMEOUT_OCCURRED: + handleTimeout(); + break; + case POSSIBLE_RACE_CONDITION_DETECTED: + handleRaceCondition(m); + break; + default: + Log.w(TAG, "Unknown message type: " + m); + return false; + } + return true; + } + + private void handleResourceIdled(Message m) { + idleState.set(m.arg1, true); + if (idleState.cardinality() == resources.size()) { + try { + idleNotificationCallback.allResourcesIdle(); + } finally { + deregister(); + } + } + } + + private void handleTimeoutWarning() { + List<String> busyResources = getBusyResources(); + if (busyResources == null) { + // null indicates that there is either a race or a programming error + // a race detector message has been inserted into the q. + // reinsert the idle_warning_reached message into the q directly after it + // so we generate warnings if the system is still sane. + handler.sendMessage(handler.obtainMessage(IDLE_WARNING_REACHED, TIMEOUT_MESSAGE_TAG)); + } else { + IdlingPolicy warning = IdlingPolicies.getDynamicIdlingResourceWarningPolicy(); + idleNotificationCallback.resourcesStillBusyWarning(busyResources); + handler.sendMessageDelayed( + handler.obtainMessage(IDLE_WARNING_REACHED, TIMEOUT_MESSAGE_TAG), + warning.getIdleTimeoutUnit().toMillis(warning.getIdleTimeout())); + } + } + + private void handleTimeout() { + List<String> busyResources = getBusyResources(); + if (busyResources == null) { + // detected a possible race... we've enqueued a race busting message + // so either that'll resolve the race or kill the app because it's buggy. + // if the race resolves, we need to timeout properly. + handler.sendMessage(handler.obtainMessage(TIMEOUT_OCCURRED, TIMEOUT_MESSAGE_TAG)); + } else { + try { + idleNotificationCallback.resourcesHaveTimedOut(busyResources); + } finally { + deregister(); + } + } + } + + @SuppressWarnings("unchecked") + private void handleRaceCondition(Message m) { + for (Integer i : (List<Integer>) m.obj) { + if (idleState.get(i)) { + // it was a race... i is now idle, everything is fine... + } else { + throw new IllegalStateException(String.format( + "Resource %s isIdleNow() is returning true, but a message indicating that the " + + "resource has transitioned from busy to idle was never sent.", + resources.get(i).getName())); + } + } + } + + private void deregister() { + handler.removeCallbacksAndMessages(TIMEOUT_MESSAGE_TAG); + idleNotificationCallback = NO_OP_CALLBACK; + } + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/InputManagerEventInjectionStrategy.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/InputManagerEventInjectionStrategy.java new file mode 100644 index 0000000..d324795 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/InputManagerEventInjectionStrategy.java @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.base; + +import static com.google.common.base.Preconditions.checkState; +import static com.google.common.base.Throwables.propagate; + +import com.google.android.apps.common.testing.ui.espresso.InjectEventSecurityException; + +import android.os.Build; +import android.util.Log; +import android.view.InputDevice; +import android.view.InputEvent; +import android.view.KeyEvent; +import android.view.MotionEvent; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +/** + * An {@link EventInjectionStrategy} that uses the input manager to inject Events. + * This strategy supports API level 16 and above. + */ +final class InputManagerEventInjectionStrategy implements EventInjectionStrategy { + private static final String TAG = InputManagerEventInjectionStrategy.class.getSimpleName(); + + // Used in reflection + private boolean initComplete; + private Method injectInputEventMethod; + private Method setSourceMotionMethod; + private Object instanceInputManagerObject; + private int motionEventMode; + private int keyEventMode; + + InputManagerEventInjectionStrategy() { + checkState(Build.VERSION.SDK_INT >= 16, "Unsupported API level."); + } + + void initialize() { + if (initComplete) { + return; + } + + try { + Log.d(TAG, "Creating injection strategy with input manager."); + + // Get the InputputManager class object and initialize if necessary. + Class<?> inputManagerClassObject = Class.forName("android.hardware.input.InputManager"); + Method getInstanceMethod = inputManagerClassObject.getDeclaredMethod("getInstance"); + getInstanceMethod.setAccessible(true); + + instanceInputManagerObject = getInstanceMethod.invoke(inputManagerClassObject); + + injectInputEventMethod = instanceInputManagerObject.getClass() + .getDeclaredMethod("injectInputEvent", InputEvent.class, Integer.TYPE); + injectInputEventMethod.setAccessible(true); + + // Setting event mode to INJECT_INPUT_EVENT_MODE_WAIT_FOR_FINISH to ensure + // that we've dispatched the event and any side effects its had on the view hierarchy + // have occurred. + Field motionEventModeField = + inputManagerClassObject.getField("INJECT_INPUT_EVENT_MODE_WAIT_FOR_FINISH"); + motionEventModeField.setAccessible(true); + motionEventMode = motionEventModeField.getInt(inputManagerClassObject); + + Field keyEventModeField = + inputManagerClassObject.getField("INJECT_INPUT_EVENT_MODE_WAIT_FOR_FINISH"); + keyEventModeField.setAccessible(true); + keyEventMode = keyEventModeField.getInt(inputManagerClassObject); + + setSourceMotionMethod = MotionEvent.class.getDeclaredMethod("setSource", Integer.TYPE); + InputEvent.class.getDeclaredMethod("getSequenceNumber"); + initComplete = true; + } catch (ClassNotFoundException e) { + propagate(e); + } catch (IllegalAccessException e) { + propagate(e); + } catch (IllegalArgumentException e) { + propagate(e); + } catch (InvocationTargetException e) { + propagate(e); + } catch (NoSuchMethodException e) { + propagate(e); + } catch (SecurityException e) { + propagate(e); + } catch (NoSuchFieldException e) { + propagate(e); + } + } + + @Override + public boolean injectKeyEvent(KeyEvent keyEvent) throws InjectEventSecurityException { + try { + return (Boolean) injectInputEventMethod.invoke(instanceInputManagerObject, + keyEvent, keyEventMode); + } catch (IllegalAccessException e) { + propagate(e); + } catch (IllegalArgumentException e) { + propagate(e); + } catch (InvocationTargetException e) { + Throwable cause = e.getCause(); + if (cause instanceof SecurityException) { + throw new InjectEventSecurityException(cause); + } + propagate(e); + } catch (SecurityException e) { + throw new InjectEventSecurityException(e); + } + return false; + } + + @Override + public boolean injectMotionEvent(MotionEvent motionEvent) throws InjectEventSecurityException { + try { + // Need to set the event source to touch screen, otherwise the input can be ignored even + // though injecting it would be successful. + // TODO(user): proper handling of events from a trackball (SOURCE_TRACKBALL) and joystick. + if ((motionEvent.getSource() & InputDevice.SOURCE_CLASS_POINTER) == 0 + && !isFromTouchpadInGlassDevice(motionEvent)) { + // Need to do runtime invocation of setSource because it was not added until 2.3_r1. + setSourceMotionMethod.invoke(motionEvent, InputDevice.SOURCE_TOUCHSCREEN); + } + return (Boolean) injectInputEventMethod.invoke(instanceInputManagerObject, + motionEvent, motionEventMode); + } catch (IllegalAccessException e) { + propagate(e); + } catch (IllegalArgumentException e) { + propagate(e); + } catch (InvocationTargetException e) { + Throwable cause = e.getCause(); + if (cause instanceof SecurityException) { + throw new InjectEventSecurityException(cause); + } + propagate(e); + } catch (SecurityException e) { + throw new InjectEventSecurityException(e); + } + return false; + } + + // We'd like to inject non-pointer events sourced from touchpad in Glass. + private static boolean isFromTouchpadInGlassDevice(MotionEvent motionEvent) { + return (Build.DEVICE.contains("glass") + || Build.DEVICE.contains("Glass") || Build.DEVICE.contains("wingman")) + && ((motionEvent.getSource() & InputDevice.SOURCE_TOUCHPAD) != 0); + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/LooperIdlingResource.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/LooperIdlingResource.java new file mode 100644 index 0000000..b75fd36 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/LooperIdlingResource.java @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.base; + +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; + +import com.google.android.apps.common.testing.ui.espresso.IdlingResource; +import com.google.android.apps.common.testing.ui.espresso.IdlingResource.ResourceCallback; +import com.google.android.apps.common.testing.ui.espresso.base.QueueInterrogator.QueueState; + +import android.os.Handler; +import android.os.Looper; +import android.os.MessageQueue.IdleHandler; + +/** + * An Idling Resource Adapter for Loopers. + */ +final class LooperIdlingResource implements IdlingResource { + + private static final String TAG = "LooperIdleResource"; + + private final boolean considerWaitIdle; + private final Looper monitoredLooper; + private final Handler monitoredHandler; + + private ResourceCallback resourceCallback; + + LooperIdlingResource(Looper monitoredLooper, boolean considerWaitIdle) { + this.monitoredLooper = checkNotNull(monitoredLooper); + this.monitoredHandler = new Handler(monitoredLooper); + this.considerWaitIdle = considerWaitIdle; + checkState(Looper.getMainLooper() != monitoredLooper, "Not for use with main looper."); + } + + // Only assigned and read from the main loop. + private QueueInterrogator queueInterrogator; + + @Override + public String getName() { + return monitoredLooper.getThread().getName(); + } + + @Override + public boolean isIdleNow() { + // on main thread here. + QueueState state = queueInterrogator.determineQueueState(); + boolean idle = state == QueueState.EMPTY || state == QueueState.TASK_DUE_LONG; + boolean idleWait = considerWaitIdle + && monitoredLooper.getThread().getState() == Thread.State.WAITING; + if (idleWait) { + if (resourceCallback != null) { + resourceCallback.onTransitionToIdle(); + } + } + return idle || idleWait; + } + + @Override + public void registerIdleTransitionCallback(ResourceCallback resourceCallback) { + this.resourceCallback = resourceCallback; + // on main thread here. + queueInterrogator = new QueueInterrogator(monitoredLooper); + + // must load idle handlers from monitored looper thread. + IdleHandler idleHandler = new ResourceCallbackIdleHandler(resourceCallback, queueInterrogator, + monitoredHandler); + + checkState(monitoredHandler.postAtFrontOfQueue(new Initializer(idleHandler)), + "Monitored looper exiting."); + } + + private static class ResourceCallbackIdleHandler implements IdleHandler { + private final ResourceCallback resourceCallback; + private final QueueInterrogator myInterrogator; + private final Handler myHandler; + + ResourceCallbackIdleHandler(ResourceCallback resourceCallback, + QueueInterrogator myInterrogator, Handler myHandler) { + this.resourceCallback = checkNotNull(resourceCallback); + this.myInterrogator = checkNotNull(myInterrogator); + this.myHandler = checkNotNull(myHandler); + } + + @Override + public boolean queueIdle() { + // invoked on the monitored looper thread. + QueueState queueState = myInterrogator.determineQueueState(); + if (queueState == QueueState.EMPTY || queueState == QueueState.TASK_DUE_LONG) { + // no block and no task coming 'shortly'. + resourceCallback.onTransitionToIdle(); + } else if (queueState == QueueState.BARRIER) { + // send a sentinal message that'll cause us to queueIdle again once the + // block is lifted. + myHandler.sendEmptyMessage(-1); + } + + return true; + } + } + + private static class Initializer implements Runnable { + private final IdleHandler myIdleHandler; + + Initializer(IdleHandler myIdleHandler) { + this.myIdleHandler = checkNotNull(myIdleHandler); + } + + @Override + public void run() { + // on monitored looper thread. + Looper.myQueue().addIdleHandler(myIdleHandler); + } + } + +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/MainThread.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/MainThread.java new file mode 100644 index 0000000..c431f48 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/MainThread.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.base; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +import javax.inject.Qualifier; + +/** + * Annotates an Executor that executes tasks on the main thread + */ +@Qualifier +@Retention(RetentionPolicy.RUNTIME) +public @interface MainThread { } diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/QueueInterrogator.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/QueueInterrogator.java new file mode 100644 index 0000000..cd63eb8 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/QueueInterrogator.java @@ -0,0 +1,169 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.base; + +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; +import static com.google.common.base.Throwables.propagate; + +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.os.MessageQueue; +import android.os.SystemClock; +import android.util.Log; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.FutureTask; + +/** + * Isolates the nasty details of touching the message queue. + */ +final class QueueInterrogator { + + enum QueueState { EMPTY, TASK_DUE_SOON, TASK_DUE_LONG, BARRIER }; + + private static final String TAG = "QueueInterrogator"; + + private static final Method messageQueueNextMethod; + private static final Field messageQueueHeadField; + private static final int LOOKAHEAD_MILLIS = 15; + + private final Looper interrogatedLooper; + private volatile MessageQueue interrogatedQueue; + + static { + Method nextMethod = null; + Field headField = null; + try { + nextMethod = MessageQueue.class.getDeclaredMethod("next"); + nextMethod.setAccessible(true); + + headField = MessageQueue.class.getDeclaredField("mMessages"); + headField.setAccessible(true); + } catch (IllegalArgumentException e) { + nextMethod = null; + headField = null; + Log.e(TAG, "Could not initialize interrogator!", e); + } catch (NoSuchFieldException e) { + nextMethod = null; + headField = null; + Log.e(TAG, "Could not initialize interrogator!", e); + } catch (NoSuchMethodException e) { + nextMethod = null; + headField = null; + Log.e(TAG, "Could not initialize interrogator!", e); + } catch (SecurityException e) { + nextMethod = null; + headField = null; + Log.e(TAG, "Could not initialize interrogator!", e); + } finally { + messageQueueNextMethod = nextMethod; + messageQueueHeadField = headField; + } + } + + QueueInterrogator(Looper interrogatedLooper) { + this.interrogatedLooper = checkNotNull(interrogatedLooper); + checkNotNull(messageQueueHeadField); + checkNotNull(messageQueueNextMethod); + } + + // Only for use by espresso - keep package private. + Message getNextMessage() { + checkThread(); + + if (null == interrogatedQueue) { + initializeQueue(); + } + + try { + return (Message) messageQueueNextMethod.invoke(Looper.myQueue()); + } catch (IllegalAccessException e) { + throw propagate(e); + } catch (IllegalArgumentException e) { + throw propagate(e); + } catch (InvocationTargetException e) { + throw propagate(e); + } catch (SecurityException e) { + throw propagate(e); + } + } + + QueueState determineQueueState() { + // may be called from any thread. + + if (null == interrogatedQueue) { + initializeQueue(); + } + synchronized (interrogatedQueue) { + try { + Message head = (Message) messageQueueHeadField.get(interrogatedQueue); + if (null == head) { + // no messages pending - AT ALL! + return QueueState.EMPTY; + } + if (null == head.getTarget()) { + // null target is a sync barrier token. + return QueueState.BARRIER; + } else { + long headWhen = head.getWhen(); + long nowFuz = SystemClock.uptimeMillis() + LOOKAHEAD_MILLIS; + + if (nowFuz > headWhen) { + return QueueState.TASK_DUE_SOON; + } else { + return QueueState.TASK_DUE_LONG; + } + } + } catch (IllegalAccessException e) { + throw propagate(e); + } + } + } + + private void initializeQueue() { + if (interrogatedLooper == Looper.myLooper()) { + interrogatedQueue = Looper.myQueue(); + } else { + Handler oneShotHandler = new Handler(interrogatedLooper); + FutureTask<MessageQueue> queueCapture = new FutureTask<MessageQueue>( + new Callable<MessageQueue>() { + @Override + public MessageQueue call() { + return Looper.myQueue(); + } + }); + oneShotHandler.postAtFrontOfQueue(queueCapture); + try { + interrogatedQueue = queueCapture.get(); + } catch (ExecutionException ee) { + throw propagate(ee.getCause()); + } catch (InterruptedException ie) { + throw propagate(ie); + } + } + } + + private void checkThread() { + checkState(interrogatedLooper == Looper.myLooper(), "Calling from non-owning thread!"); + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/RootViewPicker.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/RootViewPicker.java new file mode 100644 index 0000000..6870aa3 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/RootViewPicker.java @@ -0,0 +1,217 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.base; + +import static com.google.android.apps.common.testing.ui.espresso.matcher.RootMatchers.isDialog; +import static com.google.android.apps.common.testing.ui.espresso.matcher.RootMatchers.isFocusable; +import static com.google.common.base.Preconditions.checkState; + +import com.google.android.apps.common.testing.testrunner.ActivityLifecycleMonitor; +import com.google.android.apps.common.testing.testrunner.Stage; +import com.google.android.apps.common.testing.ui.espresso.NoActivityResumedException; +import com.google.android.apps.common.testing.ui.espresso.NoMatchingRootException; +import com.google.android.apps.common.testing.ui.espresso.Root; +import com.google.android.apps.common.testing.ui.espresso.UiController; +import com.google.common.base.Joiner; +import com.google.common.collect.Lists; + +import android.app.Activity; +import android.os.Looper; +import android.util.Log; +import android.view.View; + +import org.hamcrest.Matcher; + +import java.util.Collection; +import java.util.EnumSet; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import javax.inject.Inject; +import javax.inject.Provider; +import javax.inject.Singleton; + +/** + * Provides the root View of the top-most Window, with which the user can interact. View is + * guaranteed to be in a stable state - i.e. not pending any updates from the application. + * + * This provider can only be accessed from the main thread. + */ +@Singleton +public final class RootViewPicker implements Provider<View> { + private static final String TAG = RootViewPicker.class.getSimpleName(); + + private final Provider<List<Root>> rootsOracle; + private final UiController uiController; + private final ActivityLifecycleMonitor activityLifecycleMonitor; + private final AtomicReference<Matcher<Root>> rootMatcherRef; + + private List<Root> roots; + + @Inject + RootViewPicker(Provider<List<Root>> rootsOracle, UiController uiController, + ActivityLifecycleMonitor activityLifecycleMonitor, + AtomicReference<Matcher<Root>> rootMatcherRef) { + this.rootsOracle = rootsOracle; + this.uiController = uiController; + this.activityLifecycleMonitor = activityLifecycleMonitor; + this.rootMatcherRef = rootMatcherRef; + } + + @Override + public View get() { + checkState(Looper.getMainLooper().equals(Looper.myLooper()), "must be called on main thread."); + Matcher<Root> rootMatcher = rootMatcherRef.get(); + + Root root = findRoot(rootMatcher); + + // we only want to propagate a root view that the user can interact with and is not + // about to relay itself out. An app should be in this state the majority of the time, + // if we happen not to be in this state at the moment, process the queue some more + // we should come to it quickly enough. + int loops = 0; + + while (!isReady(root)) { + if (loops < 3) { + uiController.loopMainThreadUntilIdle(); + } else if (loops < 1001) { + + // loopUntil idle effectively is polling and pegs the CPU... if we don't have an update to + // process immediately, we might have something coming very very soon. + uiController.loopMainThreadForAtLeast(10); + } else { + // we've waited for the root view to be fully laid out and have window focus + // for over 10 seconds. something is wrong. + throw new RuntimeException(String.format("Waited for the root of the view hierarchy to have" + + " window focus and not be requesting layout for over 10 seconds. If you specified a" + + " non default root matcher, it may be picking a root that never takes focus." + + " Otherwise, something is seriously wrong. Selected Root:\n%s\n. All Roots:\n%s" + , root, Joiner.on("\n").join(roots))); + } + + root = findRoot(rootMatcher); + loops++; + } + + return root.getDecorView(); + } + + private boolean isReady(Root root) { + // Root is ready (i.e. UI is no longer in flux) if layout of the root view is not being + // requested and the root view has window focus (if it is focusable). + View rootView = root.getDecorView(); + if (!rootView.isLayoutRequested()) { + return rootView.hasWindowFocus() || !isFocusable().matches(root); + } + return false; + } + + private Root findRoot(Matcher<Root> rootMatcher) { + waitForAtLeastOneActivityToBeResumed(); + + roots = rootsOracle.get(); + + // TODO(user): move these checks into the RootsOracle. + if (roots.isEmpty()) { + // Reflection broke + throw new RuntimeException("No root window were discovered."); + } + + if (roots.size() > 1) { + // Multiple roots only occur: + // when multiple activities are in some state of their lifecycle in the application + // - we don't care about this, since we only want to interact with the RESUMED + // activity, all other activities windows are not visible to the user so, out of + // scope. + // when a PopupWindow or PopupMenu is used + // - this is a case where we definitely want to consider the top most window, since + // it probably has the most useful info in it. + // when an android.app.dialog is shown + // - again, this is getting all the users attention, so it gets the test attention + // too. + if (Log.isLoggable(TAG, Log.VERBOSE)) { + Log.v(TAG, String.format("Multiple windows detected: %s", roots)); + } + } + + List<Root> selectedRoots = Lists.newArrayList(); + for (Root root : roots) { + if (rootMatcher.matches(root)) { + selectedRoots.add(root); + } + } + + if (selectedRoots.isEmpty()) { + throw NoMatchingRootException.create(rootMatcher, roots); + } + + return reduceRoots(selectedRoots); + } + + @SuppressWarnings("unused") + private void waitForAtLeastOneActivityToBeResumed() { + Collection<Activity> resumedActivities = + activityLifecycleMonitor.getActivitiesInStage(Stage.RESUMED); + if (resumedActivities.isEmpty()) { + uiController.loopMainThreadUntilIdle(); + resumedActivities = activityLifecycleMonitor.getActivitiesInStage(Stage.RESUMED); + } + if (resumedActivities.isEmpty()) { + List<Activity> activities = Lists.newArrayList(); + for (Stage s : EnumSet.range(Stage.PRE_ON_CREATE, Stage.RESTARTED)) { + activities.addAll(activityLifecycleMonitor.getActivitiesInStage(s)); + } + if (activities.isEmpty()) { + throw new RuntimeException("No activities found. Did you forget to launch the activity " + + "by calling getActivity() or startActivitySync or similar?"); + } + // well at least there are some activities in the pipeline - lets see if they resume. + + long[] waitTimes = + {10, 50, 100, 500, TimeUnit.SECONDS.toMillis(2), TimeUnit.SECONDS.toMillis(30)}; + + for (int waitIdx = 0; waitIdx < waitTimes.length; waitIdx++) { + Log.w(TAG, "No activity currently resumed - waiting: " + waitTimes[waitIdx] + + "ms for one to appear."); + uiController.loopMainThreadForAtLeast(waitTimes[waitIdx]); + resumedActivities = activityLifecycleMonitor.getActivitiesInStage(Stage.RESUMED); + if (!resumedActivities.isEmpty()) { + return; // one of the pending activities has resumed + } + } + throw new NoActivityResumedException("No activities in stage RESUMED. Did you forget to " + + "launch the activity. (test.getActivity() or similar)?"); + } + } + + private Root reduceRoots(List<Root> subpanels) { + Root topSubpanel = subpanels.get(0); + if (subpanels.size() >= 1) { + for (Root subpanel : subpanels) { + if (isDialog().matches(subpanel)) { + return subpanel; + } + if (subpanel.getWindowLayoutParams().get().type + > topSubpanel.getWindowLayoutParams().get().type) { + topSubpanel = subpanel; + } + } + } + return topSubpanel; + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/RootsOracle.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/RootsOracle.java new file mode 100644 index 0000000..a284ede --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/RootsOracle.java @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.base; + +import static com.google.common.base.Preconditions.checkState; + +import com.google.android.apps.common.testing.ui.espresso.Root; +import com.google.common.collect.Lists; + +import android.os.Build; +import android.os.Looper; +import android.util.Log; +import android.view.View; +import android.view.WindowManager.LayoutParams; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.List; + +import javax.inject.Inject; +import javax.inject.Provider; +import javax.inject.Singleton; + +/** + * Provides access to all root views in an application. + * + * 95% of the time this is unnecessary and we can operate solely on current Activity's root view + * as indicated by getWindow().getDecorView(). However in the case of popup windows, menus, and + * dialogs the actual view hierarchy we should be operating on is not accessible thru public apis. + * + * In the spirit of degrading gracefully when new api levels break compatibility, callers should + * handle a list of size 0 by assuming getWindow().getDecorView() on the currently resumed activity + * is the sole root - this assumption will be correct often enough. + * + * Obviously, you need to be on the main thread to use this. + */ +@Singleton +final class RootsOracle implements Provider<List<Root>> { + + private static final String TAG = RootsOracle.class.getSimpleName(); + private static final String WINDOW_MANAGER_IMPL_CLAZZ = + "android.view.WindowManagerImpl"; + private static final String WINDOW_MANAGER_GLOBAL_CLAZZ = + "android.view.WindowManagerGlobal"; + private static final String VIEWS_FIELD = "mViews"; + private static final String WINDOW_PARAMS_FIELD = "mParams"; + private static final String GET_DEFAULT_IMPL = "getDefault"; + private static final String GET_GLOBAL_INSTANCE = "getInstance"; + + private final Looper mainLooper; + private boolean initialized; + private Object windowManagerObj; + private Field viewsField; + private Field paramsField; + + @Inject + RootsOracle(Looper mainLooper) { + this.mainLooper = mainLooper; + } + + @SuppressWarnings("unchecked") + @Override + public List<Root> get() { + checkState(mainLooper.equals(Looper.myLooper()), "must be called on main thread."); + + if (!initialized) { + initialize(); + } + + if (null == windowManagerObj) { + Log.w(TAG, "No reflective access to windowmanager object."); + return Lists.newArrayList(); + } + + if (null == viewsField) { + Log.w(TAG, "No reflective access to mViews"); + return Lists.newArrayList(); + } + if (null == paramsField) { + Log.w(TAG, "No reflective access to mPArams"); + return Lists.newArrayList(); + } + + List<View> views = null; + List<LayoutParams> params = null; + + try { + if (Build.VERSION.SDK_INT < 19) { + views = Arrays.asList((View[]) viewsField.get(windowManagerObj)); + params = Arrays.asList((LayoutParams[]) paramsField.get(windowManagerObj)); + } else { + views = (List<View>) viewsField.get(windowManagerObj); + params = (List<LayoutParams>) paramsField.get(windowManagerObj); + } + } catch (RuntimeException re) { + Log.w(TAG, String.format("Reflective access to %s or %s on %s failed.", + viewsField, paramsField, windowManagerObj), re); + return Lists.newArrayList(); + } catch (IllegalAccessException iae) { + Log.w(TAG, String.format("Reflective access to %s or %s on %s failed.", + viewsField, paramsField, windowManagerObj), iae); + return Lists.newArrayList(); + } + + + List<Root> roots = Lists.newArrayList(); + for (int i = views.size() - 1; i > -1; i--) { + roots.add( + new Root.Builder() + .withDecorView(views.get(i)) + .withWindowLayoutParams(params.get(i)) + .build()); + } + + return roots; + } + + private void initialize() { + initialized = true; + String accessClass = Build.VERSION.SDK_INT > 16 ? WINDOW_MANAGER_GLOBAL_CLAZZ + : WINDOW_MANAGER_IMPL_CLAZZ; + String instanceMethod = Build.VERSION.SDK_INT > 16 ? GET_GLOBAL_INSTANCE : GET_DEFAULT_IMPL; + + try { + Class<?> clazz = Class.forName(accessClass); + Method getMethod = clazz.getMethod(instanceMethod); + windowManagerObj = getMethod.invoke(null); + viewsField = clazz.getDeclaredField(VIEWS_FIELD); + viewsField.setAccessible(true); + paramsField = clazz.getDeclaredField(WINDOW_PARAMS_FIELD); + paramsField.setAccessible(true); + } catch (InvocationTargetException ite) { + Log.e(TAG, String.format("could not invoke: %s on %s", instanceMethod, accessClass), + ite.getCause()); + } catch (ClassNotFoundException cnfe) { + Log.e(TAG, String.format("could not find class: %s", accessClass), cnfe); + } catch (NoSuchFieldException nsfe) { + Log.e(TAG, String.format("could not find field: %s or %s on %s", WINDOW_PARAMS_FIELD, + VIEWS_FIELD, accessClass), nsfe); + } catch (NoSuchMethodException nsme) { + Log.e(TAG, String.format("could not find method: %s on %s", instanceMethod, accessClass), + nsme); + } catch (RuntimeException re) { + Log.e(TAG, String.format("reflective setup failed using obj: %s method: %s field: %s", + accessClass, instanceMethod, VIEWS_FIELD), re); + } catch (IllegalAccessException iae) { + Log.e(TAG, String.format("reflective setup failed using obj: %s method: %s field: %s", + accessClass, instanceMethod, VIEWS_FIELD), iae); + } + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/SdkAsyncTask.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/SdkAsyncTask.java new file mode 100644 index 0000000..b28255e --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/SdkAsyncTask.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.base; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +import javax.inject.Qualifier; + +/** + * Annotates a AsyncTaskMonitor as monitoring the SdkAsyncTask pool + */ +@Qualifier +@Retention(RetentionPolicy.RUNTIME) +@interface SdkAsyncTask { } diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/ThreadPoolExecutorExtractor.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/ThreadPoolExecutorExtractor.java new file mode 100644 index 0000000..1a719a3 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/ThreadPoolExecutorExtractor.java @@ -0,0 +1,154 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.base; + +import com.google.common.base.Optional; + +import android.os.Build; +import android.os.Handler; +import android.os.Looper; + +import java.lang.reflect.Field; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.FutureTask; +import java.util.concurrent.ThreadPoolExecutor; + +import javax.inject.Inject; +import javax.inject.Singleton; + +/** + * Extracts ThreadPoolExecutors used by pieces of android. + * + * We do some work to ensure that we load the classes containing these thread pools + * on the main thread, since they may have static initialization that assumes access + * to the main looper. + */ +@Singleton +final class ThreadPoolExecutorExtractor { + private static final String ASYNC_TASK_CLASS_NAME = "android.os.AsyncTask"; + private static final String MODERN_ASYNC_TASK_CLASS_NAME = + "android.support.v4.content.ModernAsyncTask"; + private static final String MODERN_ASYNC_TASK_FIELD_NAME = "THREAD_POOL_EXECUTOR"; + private static final String LEGACY_ASYNC_TASK_FIELD_NAME = "sExecutor"; + private final Handler mainHandler; + + @Inject + ThreadPoolExecutorExtractor(Looper looper) { + mainHandler = new Handler(looper); + } + + + public ThreadPoolExecutor getAsyncTaskThreadPool() { + FutureTask<Optional<ThreadPoolExecutor>> getTask = null; + if (Build.VERSION.SDK_INT < 11) { + getTask = new FutureTask<Optional<ThreadPoolExecutor>>(LEGACY_ASYNC_TASK_EXECUTOR); + } else { + getTask = new FutureTask<Optional<ThreadPoolExecutor>>(POST_HONEYCOMB_ASYNC_TASK_EXECUTOR); + } + + try { + return runOnMainThread(getTask).get().get(); + } catch (InterruptedException ie) { + throw new RuntimeException("Interrupted while trying to get the async task executor!", ie); + } catch (ExecutionException ee) { + throw new RuntimeException(ee.getCause()); + } + } + + public Optional<ThreadPoolExecutor> getCompatAsyncTaskThreadPool() { + try { + return runOnMainThread( + new FutureTask<Optional<ThreadPoolExecutor>>(MODERN_ASYNC_TASK_EXTRACTOR)).get(); + } catch (InterruptedException ie) { + throw new RuntimeException("Interrupted while trying to get the compat async executor!", ie); + } catch (ExecutionException ee) { + throw new RuntimeException(ee.getCause()); + } + } + + private <T> FutureTask<T> runOnMainThread(final FutureTask<T> futureToRun) { + if (Looper.myLooper() != Looper.getMainLooper()) { + final CountDownLatch latch = new CountDownLatch(1); + mainHandler.post(new Runnable() { + @Override + public void run() { + try { + futureToRun.run(); + } finally { + latch.countDown(); + } + } + }); + try { + latch.await(); + } catch (InterruptedException ie) { + if (!futureToRun.isDone()) { + throw new RuntimeException("Interrupted while waiting for task to complete."); + } + } + } else { + futureToRun.run(); + } + + return futureToRun; + } + + private static final Callable<Optional<ThreadPoolExecutor>> MODERN_ASYNC_TASK_EXTRACTOR = + new Callable<Optional<ThreadPoolExecutor>>() { + @Override + public Optional<ThreadPoolExecutor> call() throws Exception { + try { + Class<?> modernClazz = Class.forName(MODERN_ASYNC_TASK_CLASS_NAME); + Field executorField = modernClazz.getField(MODERN_ASYNC_TASK_FIELD_NAME); + return Optional.of((ThreadPoolExecutor) executorField.get(null)); + } catch (ClassNotFoundException cnfe) { + return Optional.<ThreadPoolExecutor>absent(); + } + } + }; + + private static final Callable<Class<?>> LOAD_ASYNC_TASK_CLASS = + new Callable<Class<?>>() { + @Override + public Class<?> call() throws Exception { + return Class.forName(ASYNC_TASK_CLASS_NAME); + } + }; + + private static final Callable<Optional<ThreadPoolExecutor>> LEGACY_ASYNC_TASK_EXECUTOR = + new Callable<Optional<ThreadPoolExecutor>>() { + @Override + public Optional<ThreadPoolExecutor> call() throws Exception { + Field executorField = LOAD_ASYNC_TASK_CLASS.call() + .getDeclaredField(LEGACY_ASYNC_TASK_FIELD_NAME); + executorField.setAccessible(true); + return Optional.of((ThreadPoolExecutor) executorField.get(null)); + } + }; + + private static final Callable<Optional<ThreadPoolExecutor>> POST_HONEYCOMB_ASYNC_TASK_EXECUTOR = + new Callable<Optional<ThreadPoolExecutor>>() { + @Override + public Optional<ThreadPoolExecutor> call() throws Exception { + Field executorField = LOAD_ASYNC_TASK_CLASS.call() + .getField(MODERN_ASYNC_TASK_FIELD_NAME); + return Optional.of((ThreadPoolExecutor) executorField.get(null)); + } + }; +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/UiControllerImpl.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/UiControllerImpl.java new file mode 100644 index 0000000..c1aaa5c --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/UiControllerImpl.java @@ -0,0 +1,535 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.base; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; +import static com.google.common.base.Throwables.propagate; + +import com.google.android.apps.common.testing.ui.espresso.IdlingPolicies; +import com.google.android.apps.common.testing.ui.espresso.IdlingPolicy; +import com.google.android.apps.common.testing.ui.espresso.InjectEventSecurityException; +import com.google.android.apps.common.testing.ui.espresso.UiController; +import com.google.android.apps.common.testing.ui.espresso.base.IdlingResourceRegistry.IdleNotificationCallback; +import com.google.android.apps.common.testing.ui.espresso.base.QueueInterrogator.QueueState; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Optional; +import com.google.common.collect.Lists; + +import android.annotation.SuppressLint; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.os.SystemClock; +import android.util.Log; +import android.view.KeyCharacterMap; +import android.view.KeyEvent; +import android.view.MotionEvent; + +import java.util.BitSet; +import java.util.EnumSet; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.FutureTask; + +import javax.inject.Inject; +import javax.inject.Singleton; + +/** + * Implementation of {@link UiController}. + */ +@Singleton +final class UiControllerImpl implements UiController, Handler.Callback { + + private static final String TAG = UiControllerImpl.class.getSimpleName(); + + private static final Callable<Void> NO_OP = new Callable<Void>() { + @Override + public Void call() { + return null; + } + }; + + /** + * Responsible for signaling a particular condition is met / verifying that signal. + */ + enum IdleCondition { + DELAY_HAS_PAST, + ASYNC_TASKS_HAVE_IDLED, + COMPAT_TASKS_HAVE_IDLED, + KEY_INJECT_HAS_COMPLETED, + MOTION_INJECTION_HAS_COMPLETED, + DYNAMIC_TASKS_HAVE_IDLED; + + /** + * Checks whether this condition has been signaled. + */ + public boolean isSignaled(BitSet conditionSet) { + return conditionSet.get(ordinal()); + } + + /** + * Resets the signal state for this condition. + */ + public void reset(BitSet conditionSet) { + conditionSet.set(ordinal(), false); + } + + /** + * Creates a message that when sent will raise the signal of this condition. + */ + public Message createSignal(Handler handler, int myGeneration) { + return Message.obtain(handler, ordinal(), myGeneration, 0, null); + } + + /** + * Handles a message that is raising a signal and updates the condition set accordingly. + * Messages from a previous generation will be ignored. + */ + public static boolean handleMessage(Message message, BitSet conditionSet, + int currentGeneration) { + IdleCondition [] allConditions = values(); + if (message.what < 0 || message.what >= allConditions.length) { + return false; + } else { + IdleCondition condition = allConditions[message.what]; + if (message.arg1 == currentGeneration) { + condition.signal(conditionSet); + } else { + Log.w(TAG, "ignoring signal of: " + condition + " from previous generation: " + + message.arg1 + " current generation: " + currentGeneration); + } + return true; + } + } + + public static BitSet createConditionSet() { + return new BitSet(values().length); + } + + /** + * Requests that the given bitset be updated to indicate that this condition has been + * signaled. + */ + protected void signal(BitSet conditionSet) { + conditionSet.set(ordinal()); + } + } + + private final EventInjector eventInjector; + private final BitSet conditionSet; + private final AsyncTaskPoolMonitor asyncTaskMonitor; + private final Optional<AsyncTaskPoolMonitor> compatTaskMonitor; + private final IdlingResourceRegistry idlingResourceRegistry; + private final ExecutorService keyEventExecutor = Executors.newSingleThreadExecutor(); + private final QueueInterrogator queueInterrogator; + private final Looper mainLooper; + + private Handler controllerHandler; + // only updated on main thread. + private boolean looping = false; + private int generation = 0; + + @VisibleForTesting + @Inject + UiControllerImpl(EventInjector eventInjector, + @SdkAsyncTask AsyncTaskPoolMonitor asyncTaskMonitor, + @CompatAsyncTask Optional<AsyncTaskPoolMonitor> compatTaskMonitor, + IdlingResourceRegistry registry, + Looper mainLooper) { + this.eventInjector = checkNotNull(eventInjector); + this.asyncTaskMonitor = checkNotNull(asyncTaskMonitor); + this.compatTaskMonitor = checkNotNull(compatTaskMonitor); + this.conditionSet = IdleCondition.createConditionSet(); + this.idlingResourceRegistry = checkNotNull(registry); + this.mainLooper = checkNotNull(mainLooper); + this.queueInterrogator = new QueueInterrogator(mainLooper); + } + + @SuppressWarnings("deprecation") + @Override + public boolean injectKeyEvent(final KeyEvent event) throws InjectEventSecurityException { + checkNotNull(event); + checkState(Looper.myLooper() == mainLooper, "Expecting to be on main thread!"); + initialize(); + loopMainThreadUntilIdle(); + + FutureTask<Boolean> injectTask = new SignalingTask<Boolean>( + new Callable<Boolean>() { + @Override + public Boolean call() throws Exception { + return eventInjector.injectKeyEvent(event); + } + }, + IdleCondition.KEY_INJECT_HAS_COMPLETED, + generation); + + // Inject the key event. + keyEventExecutor.submit(injectTask); + + loopUntil(IdleCondition.KEY_INJECT_HAS_COMPLETED); + + try { + checkState(injectTask.isDone(), "Key injection was signaled - but it wasnt done."); + return injectTask.get(); + } catch (ExecutionException ee) { + if (ee.getCause() instanceof InjectEventSecurityException) { + throw (InjectEventSecurityException) ee.getCause(); + } else { + throw new RuntimeException(ee.getCause()); + } + } catch (InterruptedException neverHappens) { + // we only call get() after done() is signaled. + // we should never block. + throw new RuntimeException("impossible.", neverHappens); + } + } + + @Override + public boolean injectMotionEvent(final MotionEvent event) throws InjectEventSecurityException { + checkNotNull(event); + checkState(Looper.myLooper() == mainLooper, "Expecting to be on main thread!"); + initialize(); + + FutureTask<Boolean> injectTask = new SignalingTask<Boolean>( + new Callable<Boolean>() { + @Override + public Boolean call() throws Exception { + return eventInjector.injectMotionEvent(event); + } + }, + IdleCondition.MOTION_INJECTION_HAS_COMPLETED, + generation); + keyEventExecutor.submit(injectTask); + loopUntil(IdleCondition.MOTION_INJECTION_HAS_COMPLETED); + try { + checkState(injectTask.isDone(), "Key injection was signaled - but it wasnt done."); + return injectTask.get(); + } catch (ExecutionException ee) { + if (ee.getCause() instanceof InjectEventSecurityException) { + throw (InjectEventSecurityException) ee.getCause(); + } else { + throw propagate(ee.getCause() != null ? ee.getCause() : ee); + } + } catch (InterruptedException neverHappens) { + // we only call get() after done() is signaled. + // we should never block. + throw propagate(neverHappens); + } finally { + loopMainThreadUntilIdle(); + } + } + + @Override + public boolean injectString(String str) throws InjectEventSecurityException { + checkNotNull(str); + checkState(Looper.myLooper() == mainLooper, "Expecting to be on main thread!"); + initialize(); + + // No-op if string is empty. + if (str.length() == 0) { + Log.w(TAG, "Supplied string is empty resulting in no-op (nothing is typed)."); + return true; + } + + boolean eventInjected = false; + KeyCharacterMap keyCharacterMap = getKeyCharacterMap(); + + // TODO(user): Investigate why not use (as suggested in javadoc of keyCharacterMap.getEvents): + // http://developer.android.com/reference/android/view/KeyEvent.html#KeyEvent(long, + // java.lang.String, int, int) + KeyEvent[] events = keyCharacterMap.getEvents(str.toCharArray()); + checkNotNull(events, "Failed to get events for string " + str); + Log.d(TAG, String.format("Injecting string: \"%s\"", str)); + + for (KeyEvent event : events) { + checkNotNull(event, String.format("Failed to get event for character (%c) with key code (%s)", + event.getKeyCode(), event.getUnicodeChar())); + + eventInjected = false; + for (int attempts = 0; !eventInjected && attempts < 4; attempts++) { + attempts++; + + // We have to change the time of an event before injecting it because + // all KeyEvents returned by KeyCharacterMap.getEvents() have the same + // time stamp and the system rejects too old events. Hence, it is + // possible for an event to become stale before it is injected if it + // takes too long to inject the preceding ones. + event = KeyEvent.changeTimeRepeat(event, SystemClock.uptimeMillis(), 0); + eventInjected = injectKeyEvent(event); + } + + if (!eventInjected) { + Log.e(TAG, String.format("Failed to inject event for character (%c) with key code (%s)", + event.getUnicodeChar(), event.getKeyCode())); + break; + } + } + + return eventInjected; + } + + @SuppressLint("InlinedApi") + @VisibleForTesting + @SuppressWarnings("deprecation") + public static KeyCharacterMap getKeyCharacterMap() { + KeyCharacterMap keyCharacterMap = null; + + // KeyCharacterMap.VIRTUAL_KEYBOARD is present from API11. + // For earlier APIs we use KeyCharacterMap.BUILT_IN_KEYBOARD + if (Build.VERSION.SDK_INT < 11) { + keyCharacterMap = KeyCharacterMap.load(KeyCharacterMap.BUILT_IN_KEYBOARD); + } else { + keyCharacterMap = KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD); + } + return keyCharacterMap; + } + + + @Override + public void loopMainThreadUntilIdle() { + initialize(); + checkState(Looper.myLooper() == mainLooper, "Expecting to be on main thread!"); + do { + EnumSet<IdleCondition> condChecks = EnumSet.noneOf(IdleCondition.class); + if (!asyncTaskMonitor.isIdleNow()) { + asyncTaskMonitor.notifyWhenIdle(new SignalingTask<Void>(NO_OP, + IdleCondition.ASYNC_TASKS_HAVE_IDLED, generation)); + + condChecks.add(IdleCondition.ASYNC_TASKS_HAVE_IDLED); + } + + if (!compatIdle()) { + compatTaskMonitor.get().notifyWhenIdle(new SignalingTask<Void>(NO_OP, + IdleCondition.COMPAT_TASKS_HAVE_IDLED, generation)); + condChecks.add(IdleCondition.COMPAT_TASKS_HAVE_IDLED); + } + + if (!idlingResourceRegistry.allResourcesAreIdle()) { + final IdlingPolicy warning = IdlingPolicies.getDynamicIdlingResourceWarningPolicy(); + final IdlingPolicy error = IdlingPolicies.getDynamicIdlingResourceErrorPolicy(); + final SignalingTask<Void> idleSignal = new SignalingTask<Void>(NO_OP, + IdleCondition.DYNAMIC_TASKS_HAVE_IDLED, generation); + idlingResourceRegistry.notifyWhenAllResourcesAreIdle(new IdleNotificationCallback() { + @Override + public void resourcesStillBusyWarning(List<String> busyResourceNames) { + warning.handleTimeout(busyResourceNames, "IdlingResources are still busy!"); + } + + @Override + public void resourcesHaveTimedOut(List<String> busyResourceNames) { + error.handleTimeout(busyResourceNames, "IdlingResources have timed out!"); + controllerHandler.post(idleSignal); + } + + @Override + public void allResourcesIdle() { + controllerHandler.post(idleSignal); + } + }); + condChecks.add(IdleCondition.DYNAMIC_TASKS_HAVE_IDLED); + } + + try { + loopUntil(condChecks); + } finally { + asyncTaskMonitor.cancelIdleMonitor(); + if (compatTaskMonitor.isPresent()) { + compatTaskMonitor.get().cancelIdleMonitor(); + } + idlingResourceRegistry.cancelIdleMonitor(); + } + } while (!asyncTaskMonitor.isIdleNow() || !compatIdle() + || !idlingResourceRegistry.allResourcesAreIdle()); + + } + + private boolean compatIdle() { + if (compatTaskMonitor.isPresent()) { + return compatTaskMonitor.get().isIdleNow(); + } else { + return true; + } + } + + @Override + public void loopMainThreadForAtLeast(long millisDelay) { + initialize(); + checkState(Looper.myLooper() == mainLooper, "Expecting to be on main thread!"); + checkState(!IdleCondition.DELAY_HAS_PAST.isSignaled(conditionSet), "recursion detected!"); + + checkArgument(millisDelay > 0); + controllerHandler.postDelayed(new SignalingTask(NO_OP, IdleCondition.DELAY_HAS_PAST, + generation), + millisDelay); + loopUntil(IdleCondition.DELAY_HAS_PAST); + loopMainThreadUntilIdle(); + } + + @Override + public boolean handleMessage(Message msg) { + if (!IdleCondition.handleMessage(msg, conditionSet, generation)) { + Log.i(TAG, "Unknown message type: " + msg); + return false; + } else { + return true; + } + } + + private void loopUntil(IdleCondition condition) { + loopUntil(EnumSet.of(condition)); + } + + /** + * Loops the main thread until all IdleConditions have been signaled. + * + * Once they've been signaled, the conditions are reset and the generation value + * is incremented. + * + * Signals should only be raised thru SignalingTask instances, and care should be + * taken to ensure that the signaling task is created before loopUntil is called. + * + * Good: + * idlingType.runOnIdle(new SignalingTask(NO_OP, IdleCondition.MY_IDLE_CONDITION, generation)); + * loopUntil(IdleCondition.MY_IDLE_CONDITION); + * + * Bad: + * idlingType.runOnIdle(new CustomCallback() { + * @Override + * public void itsDone() { + * // oh no - The creation of this signaling task is delayed until this method is + * // called, so it will not have the right value for generation. + * new SignalingTask(NO_OP, IdleCondition.MY_IDLE_CONDITION, generation).run(); + * } + * }) + * loopUntil(IdleCondition.MY_IDLE_CONDITION); + */ + private void loopUntil(EnumSet<IdleCondition> conditions) { + checkState(!looping, "Recursive looping detected!"); + looping = true; + IdlingPolicy masterIdlePolicy = IdlingPolicies.getMasterIdlingPolicy(); + try { + int loopCount = 0; + long start = SystemClock.uptimeMillis(); + long end = start + masterIdlePolicy.getIdleTimeoutUnit().toMillis( + masterIdlePolicy.getIdleTimeout()); + while (SystemClock.uptimeMillis() < end) { + boolean conditionsMet = true; + boolean shouldLogConditionState = loopCount > 0 && loopCount % 100 == 0; + + for (IdleCondition condition : conditions) { + if (!condition.isSignaled(conditionSet)) { + conditionsMet = false; + if (shouldLogConditionState) { + Log.w(TAG, "Waiting for: " + condition.name() + " for " + loopCount + " iterations."); + } else { + break; + } + } + } + + if (conditionsMet) { + QueueState queueState = queueInterrogator.determineQueueState(); + if (queueState == QueueState.EMPTY || queueState == QueueState.TASK_DUE_LONG) { + return; + } else { + Log.v( + "ESP_TRACE", + + "Barrier detected or task avaliable for running shortly."); + } + } + + Message message = queueInterrogator.getNextMessage(); + String callbackString = "unknown"; + String messageString = "unknown"; + try { + if (null == message.getCallback()) { + callbackString = "no callback."; + } else { + callbackString = message.getCallback().toString(); + } + messageString = message.toString(); + } catch (NullPointerException e) { + /* + * Ignore. android.app.ActivityThread$ActivityClientRecord#toString() fails for API level + * 15. + */ + } + + Log.v( + "ESP_TRACE", + String.format("%s: MessageQueue.next(): %s, with target: %s, callback: %s", TAG, + messageString, message.getTarget().getClass().getCanonicalName(), callbackString)); + message.getTarget().dispatchMessage(message); + message.recycle(); + loopCount++; + } + List<String> idleConditions = Lists.newArrayList(); + for (IdleCondition condition : conditions) { + if (!condition.isSignaled(conditionSet)) { + idleConditions.add(condition.name()); + } + } + masterIdlePolicy.handleTimeout(idleConditions, String.format( + "Looped for %s iterations over %s %s.", loopCount, masterIdlePolicy.getIdleTimeout(), + masterIdlePolicy.getIdleTimeoutUnit().name())); + } finally { + looping = false; + generation++; + for (IdleCondition condition : conditions) { + condition.reset(conditionSet); + } + } + } + + + private void initialize() { + if (controllerHandler == null) { + controllerHandler = new Handler(this); + } + } + + + /** + * Encapsulates posting a signal message to update the conditions set after a task has + * executed. + */ + private class SignalingTask<T> extends FutureTask<T> { + + private final IdleCondition condition; + private final int myGeneration; + + public SignalingTask(Callable<T> callable, IdleCondition condition, int myGeneration) { + super(callable); + this.condition = checkNotNull(condition); + this.myGeneration = myGeneration; + } + + @Override + protected void done() { + controllerHandler.sendMessage(condition.createSignal(controllerHandler, myGeneration)); + } + + } + +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/ViewFinderImpl.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/ViewFinderImpl.java new file mode 100644 index 0000000..30e0658 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/ViewFinderImpl.java @@ -0,0 +1,133 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.base; + +import static com.google.android.apps.common.testing.ui.espresso.util.TreeIterables.breadthFirstViewTraversal; +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; + +import com.google.android.apps.common.testing.ui.espresso.AmbiguousViewMatcherException; +import com.google.android.apps.common.testing.ui.espresso.NoMatchingViewException; +import com.google.android.apps.common.testing.ui.espresso.ViewFinder; +import com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers; +import com.google.common.base.Joiner; +import com.google.common.base.Optional; +import com.google.common.base.Predicate; +import com.google.common.collect.Iterables; +import com.google.common.collect.Iterators; +import com.google.common.collect.Lists; + +import android.os.Looper; +import android.view.View; +import android.widget.AdapterView; + +import org.hamcrest.Matcher; + +import java.util.Iterator; +import java.util.List; + +import javax.inject.Inject; +import javax.inject.Provider; + +/** + * Implementation of {@link ViewFinder}. + */ +// TODO(user): in the future we may want to collect stats here about the size of the view +// hierarchy, average matcher execution time, warn when matchers take too long to execute, etc. +public final class ViewFinderImpl implements ViewFinder { + + private final Matcher<View> viewMatcher; + private final Provider<View> rootViewProvider; + + @Inject + ViewFinderImpl(Matcher<View> viewMatcher, Provider<View> rootViewProvider) { + this.viewMatcher = viewMatcher; + this.rootViewProvider = rootViewProvider; + } + + @Override + public View getView() throws AmbiguousViewMatcherException, NoMatchingViewException { + checkMainThread(); + final Predicate<View> matcherPredicate = new MatcherPredicateAdapter<View>( + checkNotNull(viewMatcher)); + + View root = rootViewProvider.get(); + Iterator<View> matchedViewIterator = Iterables.filter( + breadthFirstViewTraversal(root), + matcherPredicate).iterator(); + + View matchedView = null; + + while (matchedViewIterator.hasNext()) { + if (matchedView != null) { + // Ambiguous! + throw new AmbiguousViewMatcherException.Builder() + .withViewMatcher(viewMatcher) + .withRootView(root) + .withView1(matchedView) + .withView2(matchedViewIterator.next()) + .withOtherAmbiguousViews(Iterators.toArray(matchedViewIterator, View.class)) + .build(); + } else { + matchedView = matchedViewIterator.next(); + } + } + if (null == matchedView) { + final Predicate<View> adapterViewPredicate = new MatcherPredicateAdapter<View>( + ViewMatchers.isAssignableFrom(AdapterView.class)); + List<View> adapterViews = Lists.newArrayList( + Iterables.filter(breadthFirstViewTraversal(root), adapterViewPredicate).iterator()); + if (adapterViews.isEmpty()) { + throw new NoMatchingViewException.Builder() + .withViewMatcher(viewMatcher) + .withRootView(root) + .build(); + } + + String warning = String.format("\nIf the target view is not part of the view hierarchy, you " + + "may need to use Espresso.onData to load it from one of the following AdapterViews:%s" + , Joiner.on("\n- ").join(adapterViews)); + throw new NoMatchingViewException.Builder() + .withViewMatcher(viewMatcher) + .withRootView(root) + .withAdapterViews(adapterViews) + .withAdapterViewWarning(Optional.of(warning)) + .build(); + } else { + return matchedView; + } + } + + private void checkMainThread() { + checkState(Thread.currentThread().equals(Looper.getMainLooper().getThread()), + "Executing a query on the view hierarchy outside of the main thread (on: %s)", + Thread.currentThread().getName()); + } + + private static class MatcherPredicateAdapter<T> implements Predicate<T> { + private final Matcher<? super T> matcher; + + private MatcherPredicateAdapter(Matcher<? super T> matcher) { + this.matcher = checkNotNull(matcher); + } + + @Override + public boolean apply(T input) { + return matcher.matches(input); + } + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/WindowManagerEventInjectionStrategy.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/WindowManagerEventInjectionStrategy.java new file mode 100644 index 0000000..05792e7 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/WindowManagerEventInjectionStrategy.java @@ -0,0 +1,150 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.base; + +import static com.google.common.base.Preconditions.checkState; +import static com.google.common.base.Throwables.propagate; + +import com.google.android.apps.common.testing.ui.espresso.InjectEventSecurityException; + +import android.os.Build; +import android.os.IBinder; +import android.util.Log; +import android.view.KeyEvent; +import android.view.MotionEvent; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +/** + * An {@link EventInjectionStrategy} that uses the window manager to inject {@link MotionEvent}s. + * This strategy supports API level 15 and below. + */ +final class WindowManagerEventInjectionStrategy implements EventInjectionStrategy { + private static final String TAG = WindowManagerEventInjectionStrategy.class.getSimpleName(); + + + WindowManagerEventInjectionStrategy() { + checkState(Build.VERSION.SDK_INT >= 7 && Build.VERSION.SDK_INT <= 15, "Unsupported API level."); + } + + // Reflection members. + private boolean initComplete; + private Object wmInstance; + private Method injectInputKeyEventMethod; + private Method injectInputMotionEventMethod; + + void initialize() { + if (initComplete) { + return; + } + + try { + Log.d(TAG, "Trying to create injection strategy."); + + Class<?> serviceManagerClassObj = Class.forName("android.os.ServiceManager"); + Method windowServiceMethod = + serviceManagerClassObj.getDeclaredMethod("getService", String.class); + windowServiceMethod.setAccessible(true); + + Object windowServiceBinderObj = windowServiceMethod.invoke(serviceManagerClassObj, "window"); + + Class<?> windowManagerStubObject = Class.forName("android.view.IWindowManager$Stub"); + Method asInterfaceMethod = + windowManagerStubObject.getDeclaredMethod("asInterface", IBinder.class); + asInterfaceMethod.setAccessible(true); + + wmInstance = asInterfaceMethod.invoke(windowManagerStubObject, windowServiceBinderObj); + + injectInputMotionEventMethod = wmInstance.getClass() + .getDeclaredMethod("injectPointerEvent", MotionEvent.class, Boolean.TYPE); + injectInputMotionEventMethod.setAccessible(true); + + injectInputKeyEventMethod = + wmInstance.getClass().getDeclaredMethod("injectKeyEvent", KeyEvent.class, Boolean.TYPE); + injectInputMotionEventMethod.setAccessible(true); + + initComplete = true; + } catch (ClassNotFoundException e) { + propagate(e); + } catch (IllegalAccessException e) { + propagate(e); + } catch (IllegalArgumentException e) { + propagate(e); + } catch (InvocationTargetException e) { + propagate(e); + } catch (NoSuchMethodException e) { + propagate(e); + } catch (SecurityException e) { + propagate(e); + } + } + + @Override + public boolean injectKeyEvent(KeyEvent keyEvent) throws InjectEventSecurityException { + try { + // From javadoc of com.android.server.WindowManagerService.injectKeyEvent: + // @param sync If true, wait for the event to be completed before returning to the caller. + // @return true if event was dispatched, false if it was dropped for any reason + // + // Key events are delivered OFF the main thread, and we block until they are processed. + return (Boolean) injectInputKeyEventMethod.invoke(wmInstance, keyEvent, true); + } catch (IllegalAccessException e) { + propagate(e); + } catch (IllegalArgumentException e) { + propagate(e); + } catch (InvocationTargetException e) { + Throwable cause = e.getCause(); + if (cause instanceof SecurityException) { + throw new InjectEventSecurityException(cause); + } + propagate(e); + } catch (SecurityException e) { + throw new InjectEventSecurityException(e); + } + return false; + } + + @Override + public boolean injectMotionEvent(MotionEvent motionEvent) throws InjectEventSecurityException { + try { + // From javadoc of com.android.server.WindowManagerService.injectKeyEvent: + // @param sync If true, wait for the event to be completed before returning to the caller. + // @return true if event was dispatched, false if it was dropped for any reason + // + // We inject the pointer with sync=true to ensure the event is dispatched before control + // is returned to our code. + return (Boolean) injectInputMotionEventMethod.invoke( + wmInstance, + motionEvent, + true /* sync */); + } catch (IllegalAccessException e) { + propagate(e); + } catch (IllegalArgumentException e) { + propagate(e); + } catch (InvocationTargetException e) { + Throwable cause = e.getCause(); + if (cause instanceof SecurityException) { + throw new InjectEventSecurityException(cause); + } + propagate(e); + } catch (SecurityException e) { + throw new InjectEventSecurityException(e); + } + return false; + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/contrib/CountingIdlingResource.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/contrib/CountingIdlingResource.java new file mode 100644 index 0000000..c67f199 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/contrib/CountingIdlingResource.java @@ -0,0 +1,210 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.contrib; + +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; + +import com.google.android.apps.common.testing.ui.espresso.IdlingResource; + +import android.os.SystemClock; +import android.util.Log; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * An implementation of {@link IdlingResource} that determines idleness by maintaining an internal + * counter. When the counter is 0 - it is considered to be idle, when it is non-zero it is not + * idle. This is very similar to the way a {@link java.util.concurrent.Semaphore} behaves. + * <p> + * The counter may be incremented or decremented from any thread. If it reaches an illogical state + * (like counter less than zero) it will throw an IllegalStateException. + * </p> + * <p> + * This class can then be used to wrap up operations that while in progress should block tests from + * accessing the UI. + * </p> + * + * <pre> + * {@code + * public interface FooServer { + * public Foo newFoo(); + * public void updateFoo(Foo foo); + * } + * + * public DecoratedFooServer implements FooServer { + * private final FooServer realFooServer; + * private final CountingIdlingResource fooServerIdlingResource; + * + * public DecoratedFooServer(FooServer realFooServer, + * CountingIdlingResource fooServerIdlingResource) { + * this.realFooServer = checkNotNull(realFooServer); + * this.fooServerIdlingResource = checkNotNull(fooServerIdlingResource); + * } + * + * public Foo newFoo() { + * fooServerIdlingResource.increment(); + * try { + * return realFooServer.newFoo(); + * } finally { + * fooServerIdlingResource.decrement(); + * } + * } + * + * public void updateFoo(Foo foo) { + * fooServerIdlingResource.increment(); + * try { + * realFooServer.updateFoo(foo); + * } finally { + * fooServerIdlingResource.decrement(); + * } + * } + * } + * } + * </pre> + * + * Then in your test setup: + * <pre> + * {@code + * public void setUp() throws Exception { + * super.setUp(); + * FooServer realServer = FooApplication.getFooServer(); + * CountingIdlingResource countingResource = new CountingIdlingResource("FooServerCalls"); + * FooApplication.setFooServer(new DecoratedFooServer(realServer, countingResource)); + * Espresso.registerIdlingResource(countingResource); + * } + * } + * </pre> + * + */ +@SuppressWarnings("javadoc") +public final class CountingIdlingResource implements IdlingResource { + private static final String TAG = "CountingIdlingResource"; + private final String resourceName; + private final AtomicInteger counter = new AtomicInteger(0); + private final boolean debugCounting; + + // written from main thread, read from any thread. + private volatile ResourceCallback resourceCallback; + + // read/written from any thread - used for debugging messages. + private volatile long becameBusyAt = 0; + private volatile long becameIdleAt = 0; + + /** + * Creates a CountingIdlingResource without debug tracing. + * + * @param resourceName the resource name this resource should report to Espresso. + */ + public CountingIdlingResource(String resourceName) { + this(resourceName, false); + } + + /** + * Creates a CountingIdlingResource. + * + * @param resourceName the resource name this resource should report to Espresso. + * @param debugCounting if true increment & decrement calls will print trace information to logs. + */ + public CountingIdlingResource(String resourceName, boolean debugCounting) { + this.resourceName = checkNotNull(resourceName); + this.debugCounting = debugCounting; + } + + @Override + public String getName() { + return resourceName; + } + + @Override + public boolean isIdleNow() { + return counter.get() == 0; + } + + @Override + public void registerIdleTransitionCallback(ResourceCallback resourceCallback) { + this.resourceCallback = resourceCallback; + } + + /** + * Increments the count of in-flight transactions to the resource being monitored. + * + * This method can be called from any thread. + */ + public void increment() { + int counterVal = counter.getAndIncrement(); + if (0 == counterVal) { + becameBusyAt = SystemClock.uptimeMillis(); + } + + if (debugCounting) { + Log.i(TAG, "Resource: " + resourceName + " in-use-count incremented to: " + (counterVal + 1)); + } + } + + /** + * Decrements the count of in-flight transactions to the resource being monitored. + * + * If this operation results in the counter falling below 0 - an exception is raised. + * + * @throws IllegalStateException if the counter is below 0. + */ + public void decrement() { + int counterVal = counter.decrementAndGet(); + + if (counterVal == 0) { + // we've gone from non-zero to zero. That means we're idle now! Tell espresso. + if (null != resourceCallback) { + resourceCallback.onTransitionToIdle(); + } + becameIdleAt = SystemClock.uptimeMillis(); + } + + if (debugCounting) { + if (counterVal == 0) { + Log.i(TAG, "Resource: " + resourceName + " went idle! (Time spent not idle: " + + (becameIdleAt - becameBusyAt) + ")"); + } else { + Log.i(TAG, "Resource: " + resourceName + " in-use-count decremented to: " + counterVal); + } + } + checkState(counterVal > -1, "Counter has been corrupted!"); + } + + /** + * Prints the current state of this resource to the logcat at info level. + */ + public void dumpStateToLogs() { + StringBuilder message = new StringBuilder("Resource: ") + .append(resourceName) + .append(" inflight transaction count: ") + .append(counter.get()); + if (0 == becameBusyAt) { + Log.i(TAG, message.append(" and has never been busy!").toString()); + } else { + message.append(" and was last busy at: ") + .append(becameBusyAt); + if (0 == becameIdleAt) { + Log.w(TAG, message.append(" AND NEVER WENT IDLE!").toString()); + } else { + message.append(" and last went idle at: ") + .append(becameIdleAt); + Log.i(TAG, message.toString()); + } + } + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/matcher/BoundedMatcher.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/matcher/BoundedMatcher.java new file mode 100644 index 0000000..55e8dde --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/matcher/BoundedMatcher.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.matcher; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import org.hamcrest.BaseMatcher; + +/** + * Some matcher sugar that lets you create a matcher for a given type + * but only process items of a specific subtype of that matcher. + * + * @param <T> The desired type of the Matcher. + * @param <S> the subtype of T that your matcher applies safely to. + */ +public abstract class BoundedMatcher<T, S extends T> extends BaseMatcher<T> { + + private final Class<?> expectedType; + private final Class<?>[] interfaceTypes; + + public BoundedMatcher(Class<? extends S> expectedType) { + this.expectedType = checkNotNull(expectedType); + this.interfaceTypes = new Class[0]; + } + + public BoundedMatcher(Class<?> expectedType, Class<?> interfaceType1, + Class<?>... otherInterfaces) { + this.expectedType = checkNotNull(expectedType); + checkNotNull(otherInterfaces); + int interfaceCount = otherInterfaces.length + 1; + this.interfaceTypes = new Class[interfaceCount]; + + interfaceTypes[0] = checkNotNull(interfaceType1); + checkArgument(interfaceType1.isInterface()); + int interfaceTypeIdx = 1; + for (Class<?> intfType : otherInterfaces) { + interfaceTypes[interfaceTypeIdx] = checkNotNull(intfType); + checkArgument(intfType.isInterface()); + interfaceTypeIdx++; + } + } + + protected abstract boolean matchesSafely(S item); + + @Override + @SuppressWarnings({"unchecked"}) + public final boolean matches(Object item) { + if (item == null) { + return false; + } + + if (expectedType.isInstance(item)) { + for (Class<?> intfType : interfaceTypes) { + if (!intfType.isInstance(item)) { + return false; + } + } + return matchesSafely((S) item); + } + return false; + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/matcher/PreferenceMatchers.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/matcher/PreferenceMatchers.java new file mode 100644 index 0000000..13b6506 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/matcher/PreferenceMatchers.java @@ -0,0 +1,185 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.matcher; + +import static org.hamcrest.Matchers.is; + +import android.content.res.Resources; +import android.preference.Preference; + +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeMatcher; + +/** + * A collection of hamcrest matchers that match {@link Preference}s. + */ +public final class PreferenceMatchers { + + private PreferenceMatchers() {} + + public static Matcher<Preference> withSummary(final int resourceId) { + return new TypeSafeMatcher<Preference>() { + private String resourceName = null; + private String expectedText = null; + + @Override + public void describeTo(Description description) { + description.appendText(" with summary string from resource id: "); + description.appendValue(resourceId); + if (null != resourceName) { + description.appendText("["); + description.appendText(resourceName); + description.appendText("]"); + } + if (null != expectedText) { + description.appendText(" value: " ); + description.appendText(expectedText); + } + } + + @Override + public boolean matchesSafely(Preference preference) { + if (null == expectedText) { + try { + expectedText = preference.getContext().getResources().getString(resourceId); + resourceName = preference.getContext().getResources().getResourceEntryName(resourceId); + } catch (Resources.NotFoundException ignored) { + /* view could be from a context unaware of the resource id. */ + } + } + if (null != expectedText) { + return expectedText.equals(preference.getSummary().toString()); + } else { + return false; + } + } + }; + } + + public static Matcher<Preference> withSummaryText(String summary) { + return withSummaryText(is(summary)); + } + + public static Matcher<Preference> withSummaryText(final Matcher<String> summaryMatcher) { + return new TypeSafeMatcher<Preference>() { + @Override + public void describeTo(Description description) { + description.appendText(" a preference with summary matching: "); + summaryMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(Preference pref) { + String summary = pref.getSummary().toString(); + return summaryMatcher.matches(summary); + } + }; + } + + public static Matcher<Preference> withTitle(final int resourceId) { + return new TypeSafeMatcher<Preference>() { + private String resourceName = null; + private String expectedText = null; + + @Override + public void describeTo(Description description) { + description.appendText(" with title string from resource id: "); + description.appendValue(resourceId); + if (null != resourceName) { + description.appendText("["); + description.appendText(resourceName); + description.appendText("]"); + } + if (null != expectedText) { + description.appendText(" value: " ); + description.appendText(expectedText); + } + } + + @Override + public boolean matchesSafely(Preference preference) { + if (null == expectedText) { + try { + expectedText = preference.getContext().getResources().getString(resourceId); + resourceName = preference.getContext().getResources().getResourceEntryName(resourceId); + } catch (Resources.NotFoundException ignored) { + /* view could be from a context unaware of the resource id. */ + } + } + if (null != expectedText) { + return expectedText.equals(preference.getTitle().toString()); + } else { + return false; + } + } + }; + } + + public static Matcher<Preference> withTitleText(String title) { + return withTitleText(is(title)); + } + + public static Matcher<Preference> withTitleText(final Matcher<String> titleMatcher) { + return new TypeSafeMatcher<Preference>() { + @Override + public void describeTo(Description description) { + description.appendText(" a preference with title matching: "); + titleMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(Preference pref) { + String title = pref.getTitle().toString(); + return titleMatcher.matches(title); + } + }; + } + + public static Matcher<Preference> isEnabled() { + return new TypeSafeMatcher<Preference>() { + @Override + public void describeTo(Description description) { + description.appendText(" is an enabled preference"); + } + + @Override + public boolean matchesSafely(Preference pref) { + return pref.isEnabled(); + } + }; + } + + public static Matcher<Preference> withKey(String key) { + return withKey(is(key)); + } + + public static Matcher<Preference> withKey(final Matcher<String> keyMatcher) { + return new TypeSafeMatcher<Preference>() { + @Override + public void describeTo(Description description) { + description.appendText(" preference with key matching: "); + keyMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(Preference pref) { + return keyMatcher.matches(pref.getKey()); + } + }; + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/matcher/RootMatchers.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/matcher/RootMatchers.java new file mode 100644 index 0000000..03be6c7 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/matcher/RootMatchers.java @@ -0,0 +1,217 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.matcher; + +import static com.google.common.base.Preconditions.checkNotNull; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.anyOf; + +import com.google.android.apps.common.testing.testrunner.ActivityLifecycleMonitor; +import com.google.android.apps.common.testing.testrunner.ActivityLifecycleMonitorRegistry; +import com.google.android.apps.common.testing.testrunner.Stage; +import com.google.android.apps.common.testing.ui.espresso.NoActivityResumedException; +import com.google.android.apps.common.testing.ui.espresso.Root; +import com.google.common.collect.Lists; + +import android.app.Activity; +import android.os.IBinder; +import android.view.View; +import android.view.WindowManager; + +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeMatcher; + +import java.util.Collection; +import java.util.List; + +/** + * A collection of matchers for {@link Root} objects. + */ +public final class RootMatchers { + + private RootMatchers() {} + + /** + * Espresso's default {@link Root} matcher. + */ + @SuppressWarnings("unchecked") + public static final Matcher<Root> DEFAULT = + allOf( + hasWindowLayoutParams(), + allOf( + anyOf( + allOf(isDialog(), withDecorView(hasWindowFocus())), + isSubwindowOfCurrentActivity()), + isFocusable())); + + + /** + * Matches {@link Root}s that can take window focus. + */ + public static Matcher<Root> isFocusable() { + return new TypeSafeMatcher<Root>() { + + @Override + public void describeTo(Description description) { + description.appendText("is focusable"); + } + + @Override + public boolean matchesSafely(Root root) { + int flags = root.getWindowLayoutParams().get().flags; + boolean r = !((flags & WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE) != 0); + return r; + } + }; + } + + /** + * Matches {@link Root}s that can receive touch events. + */ + public static Matcher<Root> isTouchable() { + return new TypeSafeMatcher<Root>() { + + @Override + public void describeTo(Description description) { + description.appendText("is touchable"); + } + + @Override + public boolean matchesSafely(Root root) { + int flags = root.getWindowLayoutParams().get().flags; + boolean r = !((flags & WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE) != 0); + return r; + } + }; + } + + /** + * Matches {@link Root}s that are dialogs (i.e. is not a window of the currently resumed + * activity). + */ + public static Matcher<Root> isDialog() { + return new TypeSafeMatcher<Root>() { + + @Override + public void describeTo(Description description) { + description.appendText("is dialog"); + } + + @Override + public boolean matchesSafely(Root root) { + int type = root.getWindowLayoutParams().get().type; + if ((type != WindowManager.LayoutParams.TYPE_BASE_APPLICATION + && type < WindowManager.LayoutParams.LAST_APPLICATION_WINDOW)) { + IBinder windowToken = root.getDecorView().getWindowToken(); + IBinder appToken = root.getDecorView().getApplicationWindowToken(); + if (windowToken == appToken) { + // windowToken == appToken means this window isn't contained by any other windows. + // if it was a window for an activity, it would have TYPE_BASE_APPLICATION. + // therefore it must be a dialog box. + return true; + } + } + return false; + } + }; + } + + /** + * Matches {@link Root}s with decor views that match the given view matcher. + */ + public static Matcher<Root> withDecorView(final Matcher<View> decorViewMatcher) { + checkNotNull(decorViewMatcher); + return new TypeSafeMatcher<Root>() { + + @Override + public void describeTo(Description description) { + description.appendText("with decor view "); + decorViewMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(Root root) { + return decorViewMatcher.matches(root.getDecorView()); + } + }; + } + + private static Matcher<View> hasWindowFocus() { + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("has window focus"); + } + + @Override + public boolean matchesSafely(View view) { + return view.hasWindowFocus(); + } + }; + } + + private static Matcher<Root> hasWindowLayoutParams() { + return new TypeSafeMatcher<Root>() { + + @Override + public void describeTo(Description description) { + description.appendText("has window layout params"); + } + + @Override + public boolean matchesSafely(Root root) { + if (!root.getWindowLayoutParams().isPresent()) { + return false; + } + return true; + } + }; + } + + private static Matcher<Root> isSubwindowOfCurrentActivity() { + return new TypeSafeMatcher<Root>() { + + @Override + public void describeTo(Description description) { + description.appendText("is subwindow of current activity"); + } + + @Override + public boolean matchesSafely(Root root) { + boolean r = + getResumedActivityTokens().contains(root.getDecorView().getApplicationWindowToken()); + return r; + } + }; + } + + private static List<IBinder> getResumedActivityTokens() { + ActivityLifecycleMonitor activityLifecycleMonitor = + ActivityLifecycleMonitorRegistry.getInstance(); + Collection<Activity> resumedActivities = + activityLifecycleMonitor.getActivitiesInStage(Stage.RESUMED); + if (resumedActivities.isEmpty()) { + throw new NoActivityResumedException("At least one activity should be in RESUMED stage."); + } + List<IBinder> tokens = Lists.newArrayList(); + for (Activity activity : resumedActivities) { + tokens.add(activity.getWindow().getDecorView().getApplicationWindowToken()); + } + return tokens; + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/matcher/ViewMatchers.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/matcher/ViewMatchers.java new file mode 100644 index 0000000..286f494 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/matcher/ViewMatchers.java @@ -0,0 +1,809 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.matcher; + +import static com.google.android.apps.common.testing.ui.espresso.util.TreeIterables.breadthFirstViewTraversal; +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; +import static org.hamcrest.Matchers.is; + +import com.google.android.apps.common.testing.ui.espresso.util.HumanReadables; +import com.google.common.base.Predicate; +import com.google.common.collect.Iterables; + +import android.content.res.Resources; +import android.graphics.Rect; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import android.widget.Checkable; +import android.widget.TextView; + +import junit.framework.AssertionFailedError; + +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.Matchers; +import org.hamcrest.StringDescription; +import org.hamcrest.TypeSafeMatcher; + +import java.util.Iterator; + +/** + * A collection of hamcrest matchers that match {@link View}s. + */ +public final class ViewMatchers { + + private ViewMatchers() {} + + /** + * Returns a matcher that matches Views which are an instance of or subclass of the provided + * class. Some versions of Hamcrest make the generic typing of this a nightmare, so we have a + * special case for our users. + */ + public static Matcher<View> isAssignableFrom(final Class<? extends View> clazz) { + checkNotNull(clazz); + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("is assignable from class: " + clazz); + } + + @Override + public boolean matchesSafely(View view) { + return clazz.isAssignableFrom(view.getClass()); + } + }; + } + + /** + * Returns a matcher that matches Views with class name matching the given matcher. + */ + public static Matcher<View> withClassName(final Matcher<String> classNameMatcher) { + checkNotNull(classNameMatcher); + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("with class name: "); + classNameMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(View view) { + return classNameMatcher.matches(view.getClass().getName()); + } + }; + } + + /** + * Returns a matcher that matches {@link View}s that are currently displayed on the screen to the + * user. + * + * Note: isDisplayed will select views that are partially displayed (eg: the full height/width of + * the view is greater then the height/width of the visible rectangle). If you wish to ensure the + * entire rectangle this view draws is displayed to the user use isCompletelyDisplayed. + */ + public static Matcher<View> isDisplayed() { + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("is displayed on the screen to the user"); + } + + @Override + public boolean matchesSafely(View view) { + return view.getGlobalVisibleRect(new Rect()) && + withEffectiveVisibility(Visibility.VISIBLE).matches(view); + } + }; + } + + /** + * Returns a matcher which only accepts a view whose height and width fit perfectly within + * the currently displayed region of this view. + * + * There exist views (such as ScrollViews) whose height and width are larger then the physical + * device screen by design. Such views will _never_ be completely displayed. + */ + public static Matcher<View> isCompletelyDisplayed() { + return isDisplayingAtLeast(100); + } + + /** + * Returns a matcher which accepts a view so long as a given percentage of that view's area is + * not obscured by any other view and is thus visible to the user. + * + * @param areaPercentage an integer ranging from (0, 100] indicating how much percent of the + * surface area of the view must be shown to the user to be accepted. + */ + public static Matcher<View> isDisplayingAtLeast(final int areaPercentage) { + checkState(areaPercentage <= 100, "Cannot have over 100 percent: %s", areaPercentage); + checkState(areaPercentage > 0, "Must have a positive, non-zero value: %s", areaPercentage); + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText(String.format( + "at least %s percent of the view's area is displayed to the user.", areaPercentage)); + } + + @Override + public boolean matchesSafely(View view) { + Rect visibleParts = new Rect(); + boolean visibleAtAll = view.getGlobalVisibleRect(visibleParts); + if (!visibleAtAll) { + return false; + } + double maxArea = view.getHeight() * view.getWidth(); + double visibleArea = visibleParts.height() * visibleParts.width(); + int displayedPercentage = (int) ((visibleArea / maxArea) * 100); + + return displayedPercentage >= areaPercentage + && withEffectiveVisibility(Visibility.VISIBLE).matches(view); + } + }; + } + + + + /** + * Returns a matcher that matches {@link View}s that are enabled. + */ + public static Matcher<View> isEnabled() { + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("is enabled"); + } + + @Override + public boolean matchesSafely(View view) { + return view.isEnabled(); + } + }; + } + + /** + * Returns a matcher that matches {@link View}s that are focusable. + */ + public static Matcher<View> isFocusable() { + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("is focusable"); + } + + @Override + public boolean matchesSafely(View view) { + return view.isFocusable(); + } + }; + } + + /** + * Returns a matcher that matches {@link View}s currently have focus. + */ + public static Matcher<View> hasFocus() { + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("has focus on the screen to the user"); + } + + @Override + public boolean matchesSafely(View view) { + return view.hasFocus(); + } + }; + } + + /** + * Returns an {@link Matcher} that matches {@link View}s based on their siblings.<br> + * <br> + * This may be particularly useful when a view cannot be uniquely selected on properties such as + * text or R.id. For example: a call button is repeated several times in a contacts layout and the + * only way to differentiate the call button view is by what appears next to it (e.g. the unique + * name of the contact). + * + * @param siblingMatcher a {@link Matcher} for the sibling of the view. + */ + public static Matcher<View> hasSibling(final Matcher<View> siblingMatcher) { + checkNotNull(siblingMatcher); + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("has sibling: "); + siblingMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(View view) { + ViewParent parent = view.getParent(); + if (!(parent instanceof ViewGroup)) { + return false; + } + ViewGroup parentGroup = (ViewGroup) parent; + for (int i = 0; i < parentGroup.getChildCount(); i++) { + if (siblingMatcher.matches(parentGroup.getChildAt(i))) { + return true; + } + } + return false; + } + }; + } + + /** + * Returns an {@link Matcher} that matches {@link View}s based on content description property + * value. Sugar for withContentDescription(is("string")). + * + * @param text the text to match on. + */ + public static Matcher<View> withContentDescription(String text) { + return withContentDescription(is(text)); + } + + /** + * Returns an {@link Matcher} that matches {@link View}s based on content description property + * value. + * + * @param charSequenceMatcher a {@link CharSequence} {@link Matcher} for the content description + */ + public static Matcher<View> withContentDescription( + final Matcher<? extends CharSequence> charSequenceMatcher) { + checkNotNull(charSequenceMatcher); + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("with content description: "); + charSequenceMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(View view) { + return charSequenceMatcher.matches(view.getContentDescription()); + } + }; + } + + /** + * Sugar for withId(is(int)). + * + * @param id the resource id. + */ + public static Matcher<View> withId(int id) { + return withId(is(id)); + } + + /** + * Returns a matcher that matches {@link View}s based on resource ids. Note: Android resource ids + * are not guaranteed to be unique. You may have to pair this matcher with another one to + * guarantee a unique view selection. + * + * @param integerMatcher a Matcher for resource ids + */ + public static Matcher<View> withId(final Matcher<Integer> integerMatcher) { + checkNotNull(integerMatcher); + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("with id: "); + integerMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(View view) { + return integerMatcher.matches(view.getId()); + } + }; + } + + /** + * Returns a matcher that matches {@link View} based on tag keys. + * + * @param key to match + */ + public static Matcher<View> withTagKey(final int key) { + return withTagKey(key, Matchers.<Object>notNullValue()); + } + + /** + * Returns a matcher that matches {@link View}s based on tag keys. + * + * @param key to match + * @param objectMatcher Object to match + */ + public static Matcher<View> withTagKey(final int key, final Matcher<Object> objectMatcher) { + checkNotNull(objectMatcher); + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("with key: " + key); + objectMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(View view) { + return objectMatcher.matches(view.getTag(key)); + } + }; + } + + /** + * Returns a matcher that matches {@link View}s based on tag property values. + * + * @param tagValueMatcher a Matcher for the view's tag property value + */ + public static Matcher<View> withTagValue(final Matcher<Object> tagValueMatcher) { + checkNotNull(tagValueMatcher); + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("with tag value: "); + tagValueMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(View view) { + return tagValueMatcher.matches(view.getTag()); + } + }; + } + + /** + * Returns a matcher that matches {@link TextView} based on it's text property value. Note: View's + * Sugar for withText(is("string")). + */ + public static Matcher<View> withText(String text) { + return withText(is(text)); + } + + /** + * Returns a matcher that matches {@link TextView}s based on text property value. Note: View's + * text property is never null. If you setText(null) it will still be "". Do not use null matcher. + * + * @param stringMatcher {@link Matcher} of {@link String} with text to match + */ + public static Matcher<View> withText(final Matcher<String> stringMatcher) { + checkNotNull(stringMatcher); + return new BoundedMatcher<View, TextView>(TextView.class) { + @Override + public void describeTo(Description description) { + description.appendText("with text: "); + stringMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(TextView textView) { + return stringMatcher.matches(textView.getText().toString()); + } + }; + } + + /** + * Returns a matcher that matches a descendant of {@link TextView} that is displaying the string + * associated with the given resource id. + * + * @param resourceId the string resource the text view is expected to hold. + */ + public static Matcher<View> withText(final int resourceId) { + + return new BoundedMatcher<View, TextView>(TextView.class) { + private String resourceName = null; + private String expectedText = null; + + @Override + public void describeTo(Description description) { + description.appendText("with string from resource id: "); + description.appendValue(resourceId); + if (null != resourceName) { + description.appendText("["); + description.appendText(resourceName); + description.appendText("]"); + } + if (null != expectedText) { + description.appendText(" value: "); + description.appendText(expectedText); + } + } + + @Override + public boolean matchesSafely(TextView textView) { + if (null == expectedText) { + try { + expectedText = textView.getResources().getString(resourceId); + resourceName = textView.getResources().getResourceEntryName(resourceId); + } catch (Resources.NotFoundException ignored) { + /* view could be from a context unaware of the resource id. */ + } + } + if (null != expectedText) { + return expectedText.equals(textView.getText()); + } else { + return false; + } + } + }; + } + + /** + * Returns a matcher that accepts if and only if the view is a CompoundButton (or subtype of) and + * is in checked state. + */ + public static Matcher<View> isChecked() { + return withCheckBoxState(is(true)); + } + + /** + * Returns a matcher that accepts if and only if the view is a CompoundButton (or subtype of) and + * is not in checked state. + */ + public static Matcher<View> isNotChecked() { + return withCheckBoxState(is(false)); + } + + private static <E extends View & Checkable> Matcher<View> withCheckBoxState( + final Matcher<Boolean> checkStateMatcher) { + + return new BoundedMatcher<View, E>(View.class, Checkable.class) { + @Override + public void describeTo(Description description) { + description.appendText("with checkbox state: "); + checkStateMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(E checkable) { + return checkStateMatcher.matches(checkable.isChecked()); + } + }; + } + + /** + * Returns an {@link Matcher} that matches {@link View}s with any content description. + */ + public static Matcher<View> hasContentDescription() { + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("has content description"); + } + + @Override + public boolean matchesSafely(View view) { + return view.getContentDescription() != null; + } + }; + } + + /** + * Returns a matcher that matches {@link View}s based on the presence of a descendant in its view + * hierarchy. + * + * @param descendantMatcher the type of the descendant to match on + */ + public static Matcher<View> hasDescendant(final Matcher<View> descendantMatcher) { + checkNotNull(descendantMatcher); + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("has descendant: "); + descendantMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(final View view) { + final Predicate<View> matcherPredicate = new Predicate<View>() { + @Override + public boolean apply(View input) { + return input != view && descendantMatcher.matches(input); + } + }; + + Iterator<View> matchedViewIterator = + Iterables.filter(breadthFirstViewTraversal(view), matcherPredicate).iterator(); + + return matchedViewIterator.hasNext(); + } + }; + } + + /** + * Returns a matcher that matches {@link View}s that are clickable. + */ + public static Matcher<View> isClickable() { + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("is clickable"); + } + + @Override + public boolean matchesSafely(View view) { + return view.isClickable(); + } + }; + } + + /** + * Returns a matcher that matches {@link View}s based on the given ancestor type. + * + * @param ancestorMatcher the type of the ancestor to match on + */ + public static Matcher<View> isDescendantOfA(final Matcher<View> ancestorMatcher) { + checkNotNull(ancestorMatcher); + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("is descendant of a: "); + ancestorMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(View view) { + return checkAncestors(view.getParent(), ancestorMatcher); + } + + private boolean checkAncestors( + ViewParent viewParent, Matcher<View> ancestorMatcher) { + if (!(viewParent instanceof View)) { + return false; + } + if (ancestorMatcher.matches(viewParent)) { + return true; + } + return checkAncestors(viewParent.getParent(), ancestorMatcher); + } + }; + } + + /** + * Returns a matcher that matches {@link View}s that have "effective" visibility set to the given + * value. Effective visibility takes into account not only the view's visibility value, but also + * that of its ancestors. In case of View.VISIBLE, this means that the view and all of its + * ancestors have visibility=VISIBLE. In case of GONE and INVISIBLE, it's the opposite - any GONE + * or INVISIBLE parent will make all of its children have their effective visibility. + * + * <p> + * <p> + * Note: Contrary to what the name may imply, view visibility does not directly translate into + * whether the view is displayed on screen (use isDisplayed() for that). For example, the view and + * all of its ancestors can have visibility=VISIBLE, but the view may need to be scrolled to in + * order to be actually visible to the user. Unless you're specifically targeting the visibility + * value with your test, use isDisplayed. + */ + public static Matcher<View> withEffectiveVisibility(final Visibility visibility) { + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText( + String.format("view has effective visibility=%s", visibility)); + } + + @Override + public boolean matchesSafely(View view) { + if (visibility.getValue() == View.VISIBLE) { + if (view.getVisibility() != visibility.getValue()) { + return false; + } + while (view.getParent() != null && view.getParent() instanceof View) { + view = (View) view.getParent(); + if (view.getVisibility() != visibility.getValue()) { + return false; + } + } + return true; + } else { + if (view.getVisibility() == visibility.getValue()) { + return true; + } + while (view.getParent() != null && view.getParent() instanceof View) { + view = (View) view.getParent(); + if (view.getVisibility() == visibility.getValue()) { + return true; + } + } + return false; + } + } + }; + } + + /** + * Enumerates the possible list of values for View.getVisibility(). + */ + public enum Visibility { + VISIBLE(View.VISIBLE), INVISIBLE(View.INVISIBLE), GONE(View.GONE); + + private final int value; + + private Visibility(int value) { + this.value = value; + } + + public int getValue() { + return value; + } + } + + /** + * A matcher that accepts a view if and only if the view's parent is accepted by the provided + * matcher. + * + * @param parentMatcher the matcher to apply on getParent. + */ + public static Matcher<View> withParent(final Matcher<View> parentMatcher) { + checkNotNull(parentMatcher); + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("has parent matching: "); + parentMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(View view) { + return parentMatcher.matches(view.getParent()); + } + }; + } + + /** + * A matcher that returns true if and only if the view's child is accepted by the provided + * matcher. + * + * @param childMatcher the matcher to apply on the child views. + */ + public static Matcher<View> withChild(final Matcher<View> childMatcher) { + checkNotNull(childMatcher); + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("has child: "); + childMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(View view) { + if (!(view instanceof ViewGroup)) { + return false; + } + + ViewGroup group = (ViewGroup) view; + for (int i = 0; i < group.getChildCount(); i++) { + if (childMatcher.matches(group.getChildAt(i))) { + return true; + } + } + + return false; + } + }; + } + + + /** + * Returns a matcher that matches root {@link View}. + */ + public static Matcher<View> isRoot() { + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("is a root view."); + } + + @Override + public boolean matchesSafely(View view) { + return view.getRootView().equals(view); + } + }; + } + + /** + * Returns a matcher that matches views that support input methods. + */ + public static Matcher<View> supportsInputMethods() { + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("supports input methods"); + } + + @Override + public boolean matchesSafely(View view) { + // At first glance, it would make sense to use view.onCheckIsTextEditor, but the android + // javadoc is wishy-washy about whether authors are required to implement this method when + // implementing onCreateInputConnection. + return view.onCreateInputConnection(new EditorInfo()) != null; + } + }; + } + + /** + * Returns a matcher that matches views that support input methods (e.g. EditText) and have the + * specified IME action set in its {@link EditorInfo}. + * + * @param imeAction the IME action to match + */ + public static Matcher<View> hasImeAction(int imeAction) { + return hasImeAction(is(imeAction)); + } + + /** + * Returns a matcher that matches views that support input methods (e.g. EditText) and have the + * specified IME action set in its {@link EditorInfo}. + * + * @param imeActionMatcher a matcher for the IME action + */ + public static Matcher<View> hasImeAction(final Matcher<Integer> imeActionMatcher) { + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("has ime action: "); + imeActionMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(View view) { + EditorInfo editorInfo = new EditorInfo(); + InputConnection inputConnection = view.onCreateInputConnection(editorInfo); + if (inputConnection == null) { + return false; + } + int actionId = editorInfo.actionId != 0 ? editorInfo.actionId + : editorInfo.imeOptions & EditorInfo.IME_MASK_ACTION; + return imeActionMatcher.matches(actionId); + } + }; + } + + /** + * A replacement for MatcherAssert.assertThat that renders View objects nicely. + * + * @param actual the actual value. + * @param matcher a matcher that accepts or rejects actual. + */ + public static <T> void assertThat(T actual, Matcher<T> matcher) { + assertThat("", actual, matcher); + } + + /** + * A replacement for MatcherAssert.assertThat that renders View objects nicely. + * + * @param message the message to display. + * @param actual the actual value. + * @param matcher a matcher that accepts or rejects actual. + */ + public static <T> void assertThat(String message, T actual, Matcher<T> matcher) { + if (!matcher.matches(actual)) { + Description description = new StringDescription(); + description.appendText(message) + .appendText("\nExpected: ") + .appendDescriptionOf(matcher) + .appendText("\n Got: "); + if (actual instanceof View) { + description.appendValue(HumanReadables.describe((View) actual)); + } else { + description.appendValue(actual); + } + description.appendText("\n"); + throw new AssertionFailedError(description.toString()); + } + } +} + diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/util/HumanReadables.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/util/HumanReadables.java new file mode 100644 index 0000000..a160cea --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/util/HumanReadables.java @@ -0,0 +1,201 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.util; + +import static com.google.android.apps.common.testing.ui.espresso.util.TreeIterables.depthFirstViewTraversalWithDistance; + +import com.google.android.apps.common.testing.ui.espresso.util.TreeIterables.ViewAndDistance; +import com.google.common.base.Function; +import com.google.common.base.Joiner; +import com.google.common.base.Objects; +import com.google.common.base.Objects.ToStringHelper; +import com.google.common.base.Preconditions; +import com.google.common.base.Strings; +import com.google.common.collect.Iterables; + +import android.content.res.Resources; +import android.os.Build; +import android.util.Printer; +import android.util.StringBuilderPrinter; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import android.widget.Checkable; +import android.widget.TextView; + +import java.util.List; + +/** + * Text converters for various Android objects. + */ +public final class HumanReadables { + + private HumanReadables() {} + + /** + * Prints out an error message feature the view hierarchy starting at the rootView. + * + * @param rootView the root of the hierarchy tree to print out. + * @param problemViews list of the views that you would like to point out are causing the error + * message or null, if you want to skip this feature. + * @param errorHeader the header of the error message (should contain the description of why the + * error is happening). + * @param problemViewSuffix the message to append to the view description in the tree printout. + * Required if problemViews is supplied. Otherwise, null is acceptable. + * @return a string for human consumption. + */ + public static String getViewHierarchyErrorMessage(View rootView, + final List<View> problemViews, + String errorHeader, + final String problemViewSuffix) { + Preconditions.checkArgument(problemViews == null || problemViewSuffix != null); + StringBuilder errorMessage = new StringBuilder(errorHeader); + if (problemViewSuffix != null) { + errorMessage.append( + String.format("\nProblem views are marked with '%s' below.", problemViewSuffix)); + } + + errorMessage.append("\n\nView Hierarchy:\n"); + + Joiner.on("\n").appendTo(errorMessage, Iterables.transform( + depthFirstViewTraversalWithDistance(rootView), new Function<ViewAndDistance, String>() { + @Override + public String apply(ViewAndDistance viewAndDistance) { + String formatString = "+%s%s "; + if (problemViews != null + && problemViews.contains(viewAndDistance.getView())) { + formatString += problemViewSuffix; + } + formatString += "\n|"; + + return String.format(formatString, + Strings.padStart(">", viewAndDistance.getDistanceFromRoot() + 1, '-'), + HumanReadables.describe(viewAndDistance.getView())); + } + })); + + return errorMessage.toString(); + } + + /** + * Transforms an arbitrary view into a string with (hopefully) enough debug info. + * + * @param v nullable view + * @return a string for human consumption. + */ + public static String describe(View v) { + if (null == v) { + return "null"; + } + ToStringHelper helper = Objects.toStringHelper(v).add("id", v.getId()); + if (v.getId() != -1 && v.getResources() != null) { + try { + helper.add("res-name", v.getResources().getResourceEntryName(v.getId())); + } catch (Resources.NotFoundException ignore) { + // Do nothing. + } + } + if (null != v.getContentDescription()) { + helper.add("desc", v.getContentDescription()); + } + + switch (v.getVisibility()) { + case View.GONE: + helper.add("visibility", "GONE"); + break; + case View.INVISIBLE: + helper.add("visibility", "INVISIBLE"); + break; + case View.VISIBLE: + helper.add("visibility", "VISIBLE"); + break; + default: + helper.add("visibility", v.getVisibility()); + } + + helper.add("width", v.getWidth()) + .add("height", v.getHeight()) + .add("has-focus", v.hasFocus()) + .add("has-focusable", v.hasFocusable()) + .add("has-window-focus", v.hasWindowFocus()) + .add("is-clickable", v.isClickable()) + .add("is-enabled", v.isEnabled()) + .add("is-focused", v.isFocused()) + .add("is-focusable", v.isFocusable()) + .add("is-layout-requested", v.isLayoutRequested()) + .add("is-selected", v.isSelected()); + + if (null != v.getRootView()) { + // pretty much only true in unit-tests. + helper.add("root-is-layout-requested", v.getRootView().isLayoutRequested()); + } + + EditorInfo ei = new EditorInfo(); + InputConnection ic = v.onCreateInputConnection(ei); + boolean hasInputConnection = ic != null; + helper.add("has-input-connection", hasInputConnection); + if (hasInputConnection) { + StringBuilder sb = new StringBuilder(); + sb.append("["); + Printer p = new StringBuilderPrinter(sb); + ei.dump(p, ""); + sb.append("]"); + helper.add("editor-info", sb.toString().replace("\n", " ")); + } + + if (Build.VERSION.SDK_INT > 10) { + helper.add("x", v.getX()).add("y", v.getY()); + } + + if (v instanceof TextView) { + innerDescribe((TextView) v, helper); + } + if (v instanceof Checkable) { + innerDescribe((Checkable) v, helper); + } + if (v instanceof ViewGroup) { + innerDescribe((ViewGroup) v, helper); + } + return helper.toString(); + } + + private static void innerDescribe(TextView textBox, ToStringHelper helper) { + if (null != textBox.getText()) { + helper.add("text", textBox.getText()); + } + + if (null != textBox.getError()) { + helper.add("error-text", textBox.getError()); + } + + if (null != textBox.getHint()) { + helper.add("hint", textBox.getHint()); + } + + helper.add("input-type", textBox.getInputType()); + helper.add("ime-target", textBox.isInputMethodTarget()); + } + + private static void innerDescribe(Checkable checkable, ToStringHelper helper) { + helper.add("is-checked", checkable.isChecked()); + } + + private static void innerDescribe(ViewGroup viewGroup, ToStringHelper helper) { + helper.add("child-count", viewGroup.getChildCount()); + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/util/TreeIterables.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/util/TreeIterables.java new file mode 100644 index 0000000..7fd0c4f --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/util/TreeIterables.java @@ -0,0 +1,300 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso.util; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Function; +import com.google.common.collect.AbstractIterator; +import com.google.common.collect.Iterables; +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; + +import android.view.View; +import android.view.ViewGroup; + +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +/** + * Utility methods for iterating over tree structured items. + * + * Since the view hierarchy is a tree - having a method of iterating over its contents + * is useful. + * + * This is generalized for any object which can display tree like qualities - but this + * generalization was done for testability concerns (since creating View hierarchies is a pain). + * + * Only public methods of this utility class are considered public API of the test framework. + */ +public final class TreeIterables { + private static final TreeViewer<View> VIEW_TREE_VIEWER = new ViewTreeViewer(); + + private TreeIterables() { } + + /** + * Creates an iterable that traverses the tree formed by the given root. + * + * Along with iteration order, the distance from the root element is also tracked. + * + * @param root the root view to track from. + * @return An iterable of ViewAndDistance containing the view tree in a depth first order with + * the distance of a given node from the root. + */ + public static Iterable<ViewAndDistance> depthFirstViewTraversalWithDistance(View root) { + final DistanceRecordingTreeViewer<View> distanceRecorder = + new DistanceRecordingTreeViewer<View>(root, VIEW_TREE_VIEWER); + + + return Iterables.transform( + depthFirstTraversal(root, distanceRecorder), + new Function<View, ViewAndDistance>() { + @Override + public ViewAndDistance apply(View view) { + return new ViewAndDistance(view, distanceRecorder.getDistance(view)); + } + }); + } + + /** + * Returns an iterable which iterates thru the provided view and its children in a + * depth-first, in-order traversal. That is to say that for a view such as: + * Root + * / | \ + * A R U + * /| |\ + * B D G N + * Will be iterated: Root, A, B, D, R, G, N, U. + * + * @param root the non-null, root view. + */ + public static Iterable<View> depthFirstViewTraversal(View root) { + return depthFirstTraversal(root, VIEW_TREE_VIEWER); + } + + /** + * Returns an iterable which iterates thru the provided view and its children in a + * breadth-first, row-level-order traversal. That is to say that for a view such as: + * Root + * / | \ + * A R U + * /| |\ + * B D G N + * Will be iterated: Root, A, R, U, B, D, G, N + * + * @param root the non-null, root view. + */ + public static Iterable<View> breadthFirstViewTraversal(View root) { + return breadthFirstTraversal(root, VIEW_TREE_VIEWER); + } + + /** + * Creates a depth first traversing iterator of the tree rooted at root. + * + * @param root the root of the tree + * @param viewer a TreeViewer which can determine leafiness of any instance of T and generate + * Iterables for the direct children of any instance of T. + */ + @VisibleForTesting + static <T> Iterable<T> depthFirstTraversal(final T root, final TreeViewer<T> viewer) { + checkNotNull(root); + checkNotNull(viewer); + return new TreeTraversalIterable<T>(root, TraversalStrategy.DEPTH_FIRST, viewer); + } + + /** + * Creates a breadth first traversing iterator of the tree rooted at root. + * + * @param root the root of the tree + * @param viewer a TreeViewer which can determine leafiness of any instance of T and generate + * Iterables for the direct children of any instance of T. + */ + @VisibleForTesting + static <T> Iterable<T> breadthFirstTraversal(final T root, final TreeViewer<T> viewer) { + checkNotNull(root); + checkNotNull(viewer); + return new TreeTraversalIterable<T>(root, TraversalStrategy.BREADTH_FIRST, viewer); + } + + /** + * Converts a tree into an Iterable of the tree's nodes presented in a given traversal order. + */ + private static class TreeTraversalIterable<T> implements Iterable<T> { + private final T root; + private final TraversalStrategy traversalStrategy; + private final TreeViewer<T> treeViewer; + + private TreeTraversalIterable(T root, TraversalStrategy traversalStrategy, + TreeViewer<T> treeViewer) { + this.root = checkNotNull(root); + this.traversalStrategy = checkNotNull(traversalStrategy); + this.treeViewer = checkNotNull(treeViewer); + } + + @Override + public Iterator<T> iterator() { + final LinkedList<T> nodes = Lists.newLinkedList(); + nodes.add(root); + return new AbstractIterator<T>() { + @Override + public T computeNext() { + if (nodes.isEmpty()) { + return endOfData(); + } else { + T nextItem = checkNotNull(traversalStrategy.next(nodes), "Null items not allowed!"); + traversalStrategy.combineNewChildren(nodes, treeViewer.children(nextItem)); + return nextItem; + } + } + }; + } + } + + private enum TraversalStrategy { + BREADTH_FIRST() { + @Override + <T> void combineNewChildren(LinkedList<T> nodes, Collection<T> newChildren) { + nodes.addAll(newChildren); + } + }, DEPTH_FIRST() { + @Override + <T> void combineNewChildren(LinkedList<T> nodes, Collection<T> newChildren) { + nodes.addAll(0, newChildren); + } + }; + + abstract <T> void combineNewChildren(LinkedList<T> nodes, Collection<T> newChildren); + <T> T next(LinkedList<T> nodes) { + return nodes.removeFirst(); + } + + } + + /** + * A TreeView providing access to the children of a given View. + * + * The only way views can have children is if they are a subclass of + * ViewGroup. + */ + @VisibleForTesting + static class ViewTreeViewer implements TreeViewer<View> { + @Override + public Collection<View> children(View view) { + checkNotNull(view); + if (view instanceof ViewGroup) { + ViewGroup group = (ViewGroup) view; + int childCount = group.getChildCount(); + List<View> children = Lists.newArrayList(); + for (int i = 0; i < childCount; i++) { + children.add(group.getChildAt(i)); + } + return children; + } else { + return Collections.<View>emptyList(); + } + } + } + + /** + * Provides a tree view of items of instance T and records their distance from + * a well known root. + * + * It is assumed that this TreeViewer will only be called with nodes that it + * has processed via its children method, or the root node itself. Otherwise it + * will not be able to determine distance from the root and an exception will be thrown. + * + * This class is stateful and only provides the correct distances after the underlying + * tree has been iterated over. + */ + @VisibleForTesting + static class DistanceRecordingTreeViewer<T> implements TreeViewer<T> { + private final T root; + private final Map<T, Integer> nodeToDistance = Maps.newHashMap(); + private final TreeViewer<T> delegateViewer; + + DistanceRecordingTreeViewer(T root, TreeViewer<T> delegateViewer) { + this.root = checkNotNull(root); + this.delegateViewer = checkNotNull(delegateViewer); + } + + int getDistance(T node) { + return checkNotNull(nodeToDistance.get(node), "Never seen %s before", node); + } + + @Override + public Collection<T> children(final T node) { + if (node == root) { + // base case. + nodeToDistance.put(node, 0); + } + + int myDistance = getDistance(node); + final int childDistance = myDistance + 1; + Collection<T> children = delegateViewer.children(node); + + for (T child : children) { + nodeToDistance.put(child, childDistance); + } + return children; + } + } + + /** + * Provides a way of viewing any instance of T as a tree so long as there exists a method + * for converting the instance of T into a Collection of that instance's direct children. + * + * This nice, sensible abstraction for dealing with objects with treelike properties was + * stolen from Guava's bug tracker. The Guava team is still working out the way trees + * should be exposed as Guava collections - so we have to provide our own. + */ + @VisibleForTesting + interface TreeViewer<T> { + + /** + * Returns a collection view of the children of this node. + */ + Collection<T> children(T instance); + } + + + + /** + * Represents the distance a given view is from the root view. + */ + public static class ViewAndDistance { + private final View view; + private final int distanceFromRoot; + + private ViewAndDistance(View view, int distanceFromRoot) { + this.view = view; + this.distanceFromRoot = distanceFromRoot; + } + + public View getView() { + return view; + } + + public int getDistanceFromRoot() { + return distanceFromRoot; + } + } +} diff --git a/espresso/espresso-sample/build.gradle b/espresso/espresso-sample/build.gradle new file mode 100644 index 0000000..908bc25 --- /dev/null +++ b/espresso/espresso-sample/build.gradle @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +apply plugin: 'android' + +repositories { + maven { url '../../../../prebuilts/tools/common/m2/repository' } + maven { url '../../../../prebuilts/tools/common/m2/internal' } +} + +android { + compileSdkVersion 19 + buildToolsVersion "19.0.3" + + packagingOptions { + exclude 'LICENSE.txt' + } + + lintOptions { + abortOnError false + } + + defaultConfig { + testInstrumentationRunner "com.google.android.apps.common.testing.testrunner.GoogleInstrumentationTestRunner" + } +} + +dependencies { + compile files('../libs/guava-14.0.1.jar') + compile 'com.android.support:support-v4:19.1.+' + compile 'com.android.support:appcompat-v7:19.1.+' + compile project(':idling-resource-interface') + androidTestCompile project(':espresso-contrib') +} diff --git a/espresso/espresso-sample/src/androidTest/java/com/google/android/apps/common/testing/ui/testapp/ActionBarTest.java b/espresso/espresso-sample/src/androidTest/java/com/google/android/apps/common/testing/ui/testapp/ActionBarTest.java new file mode 100644 index 0000000..10485c9 --- /dev/null +++ b/espresso/espresso-sample/src/androidTest/java/com/google/android/apps/common/testing/ui/testapp/ActionBarTest.java @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.testapp; + +import static com.google.android.apps.common.testing.ui.espresso.Espresso.onView; +import static com.google.android.apps.common.testing.ui.espresso.Espresso.openActionBarOverflowOrOptionsMenu; +import static com.google.android.apps.common.testing.ui.espresso.Espresso.openContextualActionModeOverflowMenu; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.click; +import static com.google.android.apps.common.testing.ui.espresso.assertion.ViewAssertions.matches; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withId; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withText; + +import android.test.ActivityInstrumentationTestCase2; +import android.test.suitebuilder.annotation.LargeTest; + +/** + * Demonstrates Espresso with action bar and contextual action mode. + * {@link openActionBarOverflowOrOptionsMenu()} opens the overflow menu from an action bar. + * {@link openContextualActionModeOverflowMenu()} opens the overflow menu from an contextual action + * mode. + */ +@LargeTest +public class ActionBarTest extends ActivityInstrumentationTestCase2<ActionBarTestActivity> { + @SuppressWarnings("deprecation") + public ActionBarTest() { + // This constructor was deprecated - but we want to support lower API levels. + super("com.google.android.apps.common.testing.ui.testapp", ActionBarTestActivity.class); + } + + @Override + public void setUp() throws Exception { + super.setUp(); + // Espresso will not launch our activity for us, we must launch it via getActivity(). + getActivity(); + } + + @SuppressWarnings("unchecked") + public void testClickActionBarItem() { + onView(withId(R.id.hide_contextual_action_bar)) + .perform(click()); + + onView(withId(R.id.action_save)) + .perform(click()); + + onView(withId(R.id.text_action_bar_result)) + .check(matches(withText("Save"))); + } + + @SuppressWarnings("unchecked") + public void testClickActionModeItem() { + onView(withId(R.id.show_contextual_action_bar)) + .perform(click()); + + onView((withId(R.id.action_lock))) + .perform(click()); + + onView(withId(R.id.text_action_bar_result)) + .check(matches(withText("Lock"))); + } + + + @SuppressWarnings("unchecked") + public void testActionBarOverflow() { + onView(withId(R.id.hide_contextual_action_bar)) + .perform(click()); + + // Open the overflow menu from action bar + openActionBarOverflowOrOptionsMenu(getInstrumentation().getTargetContext()); + + onView(withText("World")) + .perform(click()); + + onView(withId(R.id.text_action_bar_result)) + .check(matches(withText("World"))); + } + + @SuppressWarnings("unchecked") + public void testActionModeOverflow() { + onView(withId(R.id.show_contextual_action_bar)) + .perform(click()); + + // Open the overflow menu from contextual action mode. + openContextualActionModeOverflowMenu(); + + onView(withText("Key")) + .perform(click()); + + onView(withId(R.id.text_action_bar_result)) + .check(matches(withText("Key"))); + } +} diff --git a/espresso/espresso-sample/src/androidTest/java/com/google/android/apps/common/testing/ui/testapp/AdapterViewTest.java b/espresso/espresso-sample/src/androidTest/java/com/google/android/apps/common/testing/ui/testapp/AdapterViewTest.java new file mode 100644 index 0000000..fd531ec --- /dev/null +++ b/espresso/espresso-sample/src/androidTest/java/com/google/android/apps/common/testing/ui/testapp/AdapterViewTest.java @@ -0,0 +1,142 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.testapp; + +import static com.google.android.apps.common.testing.ui.espresso.Espresso.onData; +import static com.google.android.apps.common.testing.ui.espresso.Espresso.onView; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.click; +import static com.google.android.apps.common.testing.ui.espresso.assertion.ViewAssertions.matches; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withId; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withText; +import static com.google.android.apps.common.testing.ui.testapp.LongListMatchers.isFooter; +import static com.google.android.apps.common.testing.ui.testapp.LongListMatchers.withItemContent; +import static com.google.android.apps.common.testing.ui.testapp.LongListMatchers.withItemSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; + +import android.test.ActivityInstrumentationTestCase2; +import android.test.suitebuilder.annotation.LargeTest; +import android.view.View; +import android.widget.Adapter; +import android.widget.AdapterView; + +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeMatcher; + +/** + * Demonstrates the usage of + * {@link com.google.android.apps.common.testing.ui.espresso.Espresso#onData(org.hamcrest.Matcher)} + * to match data within list views. + */ +@LargeTest +public class AdapterViewTest extends ActivityInstrumentationTestCase2<LongListActivity> { + + @SuppressWarnings("deprecation") + public AdapterViewTest() { + // This constructor was deprecated - but we want to support lower API levels. + super("com.google.android.apps.common.testing.ui.testapp", LongListActivity.class); + } + + @Override + public void setUp() throws Exception { + super.setUp(); + getActivity(); + } + + public void testClickOnItem50() { + // The text view "item: 50" may not exist if we haven't scrolled to it. + // By using onData api we tell Espresso to look into the Adapter for an item matching + // the matcher we provide it. Espresso will then bring that item into the view hierarchy + // and we can click on it. + + onData(withItemContent("item: 50")) + .perform(click()); + + onView(withId(R.id.selection_row_value)) + .check(matches(withText("50"))); + } + + public void testClickOnSpecificChildOfRow60() { + onData(withItemContent("item: 60")) + .onChildView(withId(R.id.item_size)) // resource id of second column from xml layout + .perform(click()); + + onView(withId(R.id.selection_row_value)) + .check(matches(withText("60"))); + + onView(withId(R.id.selection_column_value)) + .check(matches(withText("2"))); + } + + public void testClickOnFirstAndFifthItemOfLength8() { + onData(is(withItemSize(8))) + .atPosition(0) + .perform(click()); + + onView(withId(R.id.selection_row_value)) + .check(matches(withText("10"))); + + onData(is(withItemSize(8))) + .atPosition(4) + .perform(click()); + + onView(withId(R.id.selection_row_value)) + .check(matches(withText("14"))); + } + + @SuppressWarnings("unchecked") + public void testClickFooter() { + onData(isFooter()) + .perform(click()); + + onView(withId(R.id.selection_row_value)) + .check(matches(withText("100"))); + } + + @SuppressWarnings("unchecked") + public void testDataItemNotInAdapter(){ + onView(withId(R.id.list)) + .check(matches(not(withAdaptedData(withItemContent("item: 168"))))); + } + + private static Matcher<View> withAdaptedData(final Matcher<Object> dataMatcher) { + return new TypeSafeMatcher<View>() { + + @Override + public void describeTo(Description description) { + description.appendText("with class name: "); + dataMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(View view) { + if (!(view instanceof AdapterView)) { + return false; + } + @SuppressWarnings("rawtypes") + Adapter adapter = ((AdapterView) view).getAdapter(); + for (int i = 0; i < adapter.getCount(); i++) { + if (dataMatcher.matches(adapter.getItem(i))) { + return true; + } + } + return false; + } + }; + } +} diff --git a/espresso/espresso-sample/src/androidTest/java/com/google/android/apps/common/testing/ui/testapp/AdvancedSynchronizationTest.java b/espresso/espresso-sample/src/androidTest/java/com/google/android/apps/common/testing/ui/testapp/AdvancedSynchronizationTest.java new file mode 100644 index 0000000..d19fb69 --- /dev/null +++ b/espresso/espresso-sample/src/androidTest/java/com/google/android/apps/common/testing/ui/testapp/AdvancedSynchronizationTest.java @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.testapp; + +import static com.google.android.apps.common.testing.ui.espresso.Espresso.onView; +import static com.google.android.apps.common.testing.ui.espresso.Espresso.registerIdlingResources; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.click; +import static com.google.android.apps.common.testing.ui.espresso.assertion.ViewAssertions.matches; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withId; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withText; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.android.apps.common.testing.ui.espresso.contrib.CountingIdlingResource; +import com.google.android.apps.common.testing.ui.testapp.SyncActivity.HelloWorldServer; + +import android.test.ActivityInstrumentationTestCase2; +import android.test.suitebuilder.annotation.LargeTest; + +/** + * Example for {@link CountingIdlingResource}. Demonstrates how to wait on a delayed response from + * request before continuing with a test. + */ +@LargeTest +public class AdvancedSynchronizationTest extends ActivityInstrumentationTestCase2<SyncActivity> { + + private class DecoratedHelloWorldServer implements HelloWorldServer { + private final HelloWorldServer realHelloWorldServer; + private final CountingIdlingResource helloWorldServerIdlingResource; + + private DecoratedHelloWorldServer(HelloWorldServer realHelloWorldServer, + CountingIdlingResource helloWorldServerIdlingResource) { + this.realHelloWorldServer = checkNotNull(realHelloWorldServer); + this.helloWorldServerIdlingResource = checkNotNull(helloWorldServerIdlingResource); + } + + @Override + public String getHelloWorld() { + // Use CountingIdlingResource to track in-flight calls to getHelloWorld (a simulation of a + // network call). Whenever the count goes to zero, Espresso will be notified that this + // resource is idle and the test will be able to proceed. + helloWorldServerIdlingResource.increment(); + try { + return realHelloWorldServer.getHelloWorld(); + } finally { + helloWorldServerIdlingResource.decrement(); + } + } + } + + @SuppressWarnings("deprecation") + public AdvancedSynchronizationTest() { + // This constructor was deprecated - but we want to support lower API levels. + super("com.google.android.apps.common.testing.ui.testapp", SyncActivity.class); + } + + @Override + public void setUp() throws Exception { + super.setUp(); + SyncActivity activity = getActivity(); + HelloWorldServer realServer = activity.getHelloWorldServer(); + // Here, we use CountingIdlingResource - a common convenience class - to track the idle state of + // the server. You could also do this yourself, by implementing the IdlingResource interface. + CountingIdlingResource countingResource = new CountingIdlingResource("HelloWorldServerCalls"); + activity.setHelloWorldServer(new DecoratedHelloWorldServer(realServer, countingResource)); + registerIdlingResources(countingResource); + } + + public void testCountingIdlingResource() { + // Request the "hello world!" text by clicking on the request button. + onView(withId(R.id.request_button)).perform(click()); + + // Espresso waits for the resource to go idle and then continues. + + // The check if the text is visible can pass now. + onView(withId(R.id.status_text)).check(matches(withText(R.string.hello_world))); + } +} diff --git a/espresso/espresso-sample/src/androidTest/java/com/google/android/apps/common/testing/ui/testapp/BasicTest.java b/espresso/espresso-sample/src/androidTest/java/com/google/android/apps/common/testing/ui/testapp/BasicTest.java new file mode 100644 index 0000000..ba2b282 --- /dev/null +++ b/espresso/espresso-sample/src/androidTest/java/com/google/android/apps/common/testing/ui/testapp/BasicTest.java @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.testapp; + +import static com.google.android.apps.common.testing.ui.espresso.Espresso.onData; +import static com.google.android.apps.common.testing.ui.espresso.Espresso.onView; +import static com.google.android.apps.common.testing.ui.espresso.Espresso.pressBack; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.click; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.typeText; +import static com.google.android.apps.common.testing.ui.espresso.assertion.ViewAssertions.matches; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withId; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; + +import android.test.ActivityInstrumentationTestCase2; +import android.test.suitebuilder.annotation.LargeTest; + +/** + * Highlights basic + * {@link com.google.android.apps.common.testing.ui.espresso.Espresso#onView(org.hamcrest.Matcher)} + * functionality. + */ +@LargeTest +public class BasicTest extends ActivityInstrumentationTestCase2<SimpleActivity> { + + @SuppressWarnings("deprecation") + public BasicTest() { + // This constructor was deprecated - but we want to support lower API levels. + super("com.google.android.apps.common.testing.ui.testapp", SimpleActivity.class); + } + + @Override + public void setUp() throws Exception { + super.setUp(); + // Espresso will not launch our activity for us, we must launch it via getActivity(). + getActivity(); + } + + public void testSimpleClickAndCheckText() { + onView(withId(R.id.button_simple)) + .perform(click()); + + onView(withId(R.id.text_simple)) + .check(matches(withText("Hello Espresso!"))); + } + + public void testTypingAndPressBack() { + onView(withId(R.id.sendtext_simple)) + .perform(typeText("Have a cup of Espresso.")); + + onView(withId(R.id.send_simple)) + .perform(click()); + + // Clicking launches a new activity that shows the text entered above. You don't need to do + // anything special to handle the activity transitions. Espresso takes care of waiting for the + // new activity to be resumed and its view hierarchy to be laid out. + onView(withId(R.id.display_data)) + .check(matches(withText(("Have a cup of Espresso.")))); + + // Going back to the previous activity - lets make sure our text was perserved. + pressBack(); + + onView(withId(R.id.sendtext_simple)) + .check(matches(withText(containsString("Espresso")))); + } + + @SuppressWarnings("unchecked") + public void testClickOnSpinnerItemAmericano(){ + // Open the spinner. + onView(withId(R.id.spinner_simple)) + .perform(click()); + // Spinner creates a List View with its contents - this can be very long and the element not + // contributed to the ViewHierarchy - by using onData we force our desired element into the + // view hierarchy. + onData(allOf(is(instanceOf(String.class)), is("Americano"))) + .perform(click()); + + onView(withId(R.id.spinnertext_simple)) + .check(matches(withText(containsString("Americano")))); + } +} + + diff --git a/espresso/espresso-sample/src/androidTest/java/com/google/android/apps/common/testing/ui/testapp/CustomFailureHandlerTest.java b/espresso/espresso-sample/src/androidTest/java/com/google/android/apps/common/testing/ui/testapp/CustomFailureHandlerTest.java new file mode 100644 index 0000000..14a3baf --- /dev/null +++ b/espresso/espresso-sample/src/androidTest/java/com/google/android/apps/common/testing/ui/testapp/CustomFailureHandlerTest.java @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.testapp; + +import static com.google.android.apps.common.testing.ui.espresso.Espresso.onView; +import static com.google.android.apps.common.testing.ui.espresso.Espresso.setFailureHandler; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.click; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withText; + +import com.google.android.apps.common.testing.ui.espresso.FailureHandler; +import com.google.android.apps.common.testing.ui.espresso.NoMatchingViewException; +import com.google.android.apps.common.testing.ui.espresso.base.DefaultFailureHandler; + +import android.content.Context; +import android.test.ActivityInstrumentationTestCase2; +import android.test.suitebuilder.annotation.LargeTest; +import android.util.Log; +import android.view.View; + +import org.hamcrest.Matcher; + +/** + * A sample of how to set a non-default {@link FailureHandler}. + */ +@LargeTest +public class CustomFailureHandlerTest extends ActivityInstrumentationTestCase2<MainActivity> { + + private static final String TAG = "CustomFailureHandlerTest"; + + @SuppressWarnings("deprecation") + public CustomFailureHandlerTest() { + // This constructor was deprecated - but we want to support lower API levels. + super("com.google.android.apps.common.testing.ui.testapp", MainActivity.class); + } + + @Override + public void setUp() throws Exception { + super.setUp(); + getActivity(); + setFailureHandler(new CustomFailureHandler(getInstrumentation().getTargetContext())); + } + + public void testWithCustomFailureHandler() { + try { + onView(withText("does not exist")).perform(click()); + } catch (MySpecialException expected) { + Log.e(TAG, "Special exception is special and expected: ", expected); + } + } + + /** + * A {@link FailureHandler} that re-throws {@link NoMatchingViewException} as + * {@link MySpecialException}. All other functionality is delegated to + * {@link DefaultFailureHandler}. + */ + private static class CustomFailureHandler implements FailureHandler { + private final FailureHandler delegate; + + public CustomFailureHandler(Context targetContext) { + delegate = new DefaultFailureHandler(targetContext); + } + + @Override + public void handle(Throwable error, Matcher<View> viewMatcher) { + try { + delegate.handle(error, viewMatcher); + } catch (NoMatchingViewException e) { + throw new MySpecialException(e); + } + } + } + + private static class MySpecialException extends RuntimeException { + MySpecialException(Throwable cause) { + super(cause); + } + } +} diff --git a/espresso/espresso-sample/src/androidTest/java/com/google/android/apps/common/testing/ui/testapp/DrawerActionsTest.java b/espresso/espresso-sample/src/androidTest/java/com/google/android/apps/common/testing/ui/testapp/DrawerActionsTest.java new file mode 100644 index 0000000..b7c1337 --- /dev/null +++ b/espresso/espresso-sample/src/androidTest/java/com/google/android/apps/common/testing/ui/testapp/DrawerActionsTest.java @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.testapp; + +import static com.google.android.apps.common.testing.ui.espresso.Espresso.onData; +import static com.google.android.apps.common.testing.ui.espresso.Espresso.onView; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.click; +import static com.google.android.apps.common.testing.ui.espresso.assertion.ViewAssertions.matches; +import static com.google.android.apps.common.testing.ui.espresso.contrib.DrawerActions.closeDrawer; +import static com.google.android.apps.common.testing.ui.espresso.contrib.DrawerActions.openDrawer; +import static com.google.android.apps.common.testing.ui.espresso.contrib.DrawerMatchers.isClosed; +import static com.google.android.apps.common.testing.ui.espresso.contrib.DrawerMatchers.isOpen; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withId; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; + +import com.google.android.apps.common.testing.ui.espresso.contrib.DrawerActions; + +import android.test.ActivityInstrumentationTestCase2; +import android.test.suitebuilder.annotation.LargeTest; + +/** + * Demonstrates use of {@link DrawerActions}. + */ +@LargeTest +public class DrawerActionsTest extends ActivityInstrumentationTestCase2<DrawerActivity> { + + public DrawerActionsTest() { + super(DrawerActivity.class); + } + + @Override + protected void setUp() throws Exception { + super.setUp(); + getActivity(); + } + + public void testOpenAndCloseDrawer() { + // Drawer should not be open to start. + onView(withId(R.id.drawer_layout)).check(matches(isClosed())); + + openDrawer(R.id.drawer_layout); + + // The drawer should now be open. + onView(withId(R.id.drawer_layout)).check(matches(isOpen())); + + closeDrawer(R.id.drawer_layout); + + // Drawer should be closed again. + onView(withId(R.id.drawer_layout)).check(matches(isClosed())); + } + + @SuppressWarnings("unchecked") + public void testDrawerOpenAndClick() { + openDrawer(R.id.drawer_layout); + + onView(withId(R.id.drawer_layout)).check(matches(isOpen())); + + // Click an item in the drawer. We use onData because the drawer is backed by a ListView, and + // the item may not necessarily be visible in the view hierarchy. + int rowIndex = 2; + String rowContents = DrawerActivity.DRAWER_CONTENTS[rowIndex]; + onData(allOf(is(instanceOf(String.class)), is(rowContents))).perform(click()); + + // clicking the item should close the drawer. + onView(withId(R.id.drawer_layout)).check(matches(isClosed())); + + // The text view will now display "You picked: Pickle" + onView(withId(R.id.drawer_text_view)).check(matches(withText("You picked: " + rowContents))); + } +} diff --git a/espresso/espresso-sample/src/androidTest/java/com/google/android/apps/common/testing/ui/testapp/LongListMatchers.java b/espresso/espresso-sample/src/androidTest/java/com/google/android/apps/common/testing/ui/testapp/LongListMatchers.java new file mode 100644 index 0000000..1518697 --- /dev/null +++ b/espresso/espresso-sample/src/androidTest/java/com/google/android/apps/common/testing/ui/testapp/LongListMatchers.java @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.testapp; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasEntry; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; + +import com.google.android.apps.common.testing.ui.espresso.matcher.BoundedMatcher; + +import org.hamcrest.Description; +import org.hamcrest.Matcher; + +import java.util.Map; + +/** + * Static utility methods to create {@link Matcher} instances that can be applied to the data + * objects created by {@link com.google.android.apps.common.testing.ui.testapp.LongListActivity}. + * <p> + * These matchers are used by the + * {@link com.google.android.apps.common.testing.ui.espresso.Espresso#onData(Matcher)} API and are + * applied against the data exposed by @{link android.widget.ListView#getAdapter()}. + * </p> + * <p> + * In LongListActivity's case - each row is a Map containing 2 key value pairs. The key "STR" is + * mapped to a String which will be rendered into a TextView with the R.id.item_content. The other + * key "LEN" is an Integer which is the length of the string "STR" refers to. This length is + * rendered into a TextView with the id R.id.item_size. + * </p> + */ +public final class LongListMatchers { + + private LongListMatchers() { } + + + /** + * Creates a matcher against the text stored in R.id.item_content. This text is roughly + * "item: $row_number". + */ + public static Matcher<Object> withItemContent(String expectedText) { + // use preconditions to fail fast when a test is creating an invalid matcher. + checkNotNull(expectedText); + return withItemContent(equalTo(expectedText)); + } + + /** + * Creates a matcher against the text stored in R.id.item_content. This text is roughly + * "item: $row_number". + */ + @SuppressWarnings("rawtypes") + public static Matcher<Object> withItemContent(final Matcher<String> itemTextMatcher) { + // use preconditions to fail fast when a test is creating an invalid matcher. + checkNotNull(itemTextMatcher); + return new BoundedMatcher<Object, Map>(Map.class) { + @Override + public boolean matchesSafely(Map map) { + return hasEntry(equalTo("STR"), itemTextMatcher).matches(map); + } + + @Override + public void describeTo(Description description) { + description.appendText("with item content: "); + itemTextMatcher.describeTo(description); + } + }; + } + + /** + * Creates a matcher against the text stored in R.id.item_size. This text is the size of the text + * printed in R.id.item_content. + */ + public static Matcher<Object> withItemSize(int itemSize) { + // use preconditions to fail fast when a test is creating an invalid matcher. + checkArgument(itemSize > -1); + return withItemSize(equalTo(itemSize)); + } + + /** + * Creates a matcher against the text stored in R.id.item_size. This text is the size of the text + * printed in R.id.item_content. + */ + @SuppressWarnings("rawtypes") + public static Matcher<Object> withItemSize(final Matcher<Integer> itemSizeMatcher) { + // use preconditions to fail fast when a test is creating an invalid matcher. + checkNotNull(itemSizeMatcher); + return new BoundedMatcher<Object, Map>(Map.class) { + @Override + public boolean matchesSafely(Map map) { + return hasEntry(equalTo("LEN"), itemSizeMatcher).matches(map); + } + + @Override + public void describeTo(Description description) { + description.appendText("with item size: "); + itemSizeMatcher.describeTo(description); + } + }; + } + + /** + * Creates a matcher against the footer of this list view. + */ + @SuppressWarnings("unchecked") + public static Matcher<Object> isFooter() { + // This depends on LongListActivity.FOOTER being passed as data in the addFooterView method. + return allOf(is(instanceOf(String.class)), is(LongListActivity.FOOTER)); + } + +} diff --git a/espresso/espresso-sample/src/androidTest/java/com/google/android/apps/common/testing/ui/testapp/LongListMatchersTest.java b/espresso/espresso-sample/src/androidTest/java/com/google/android/apps/common/testing/ui/testapp/LongListMatchersTest.java new file mode 100644 index 0000000..3b80757 --- /dev/null +++ b/espresso/espresso-sample/src/androidTest/java/com/google/android/apps/common/testing/ui/testapp/LongListMatchersTest.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.testapp; + +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.assertThat; +import static com.google.android.apps.common.testing.ui.testapp.LongListMatchers.withItemContent; +import static com.google.android.apps.common.testing.ui.testapp.LongListMatchers.withItemSize; +import static org.hamcrest.Matchers.anyOf; +import static org.hamcrest.Matchers.endsWith; +import static org.hamcrest.Matchers.equalTo; + +import android.content.Intent; +import android.test.ActivityUnitTestCase; + +/** + * UnitTests for LongListMatchers matcher factory. + */ +public final class LongListMatchersTest extends ActivityUnitTestCase<LongListActivity> { + + public LongListMatchersTest() { + super(LongListActivity.class); + } + + @Override + public void setUp() throws Exception { + super.setUp(); + startActivity(new Intent(getInstrumentation().getTargetContext(), LongListActivity.class), + null, null); + } + + public void testWithContent() { + assertThat(getActivity().makeItem(54), withItemContent("item: 54")); + assertThat(getActivity().makeItem(54), withItemContent(endsWith("54"))); + assertFalse(withItemContent("hello world").matches(getActivity().makeItem(54))); + } + + @SuppressWarnings("unchecked") + public void testWithItemSize() { + assertThat(getActivity().makeItem(54), withItemSize(8)); + assertThat(getActivity().makeItem(54), withItemSize(anyOf(equalTo(8), equalTo(7)))); + assertFalse(withItemSize(7).matches(getActivity().makeItem(54))); + } +} diff --git a/espresso/espresso-sample/src/androidTest/java/com/google/android/apps/common/testing/ui/testapp/MenuTest.java b/espresso/espresso-sample/src/androidTest/java/com/google/android/apps/common/testing/ui/testapp/MenuTest.java new file mode 100644 index 0000000..9ea8898 --- /dev/null +++ b/espresso/espresso-sample/src/androidTest/java/com/google/android/apps/common/testing/ui/testapp/MenuTest.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.testapp; + +import static com.google.android.apps.common.testing.ui.espresso.Espresso.onView; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.click; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.longClick; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.pressMenuKey; +import static com.google.android.apps.common.testing.ui.espresso.assertion.ViewAssertions.doesNotExist; +import static com.google.android.apps.common.testing.ui.espresso.assertion.ViewAssertions.matches; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDisplayed; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isRoot; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withId; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withText; + +import android.os.Build; +import android.test.ActivityInstrumentationTestCase2; +import android.test.suitebuilder.annotation.LargeTest; + +/** + * Ensures view root ordering works properly. + */ +@LargeTest +public class MenuTest extends ActivityInstrumentationTestCase2<MenuActivity> { + @SuppressWarnings("deprecation") + public MenuTest() { + // This constructor was deprecated - but we want to support lower API levels. + super("com.google.android.apps.common.testing.ui.testapp", MenuActivity.class); + } + + @Override + public void setUp() throws Exception { + super.setUp(); + getActivity(); + } + + public void testPopupMenu() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.HONEYCOMB) { + // popup menus are post honeycomb. + return; + } + onView(withText(R.string.popup_item_1_text)).check(doesNotExist()); + onView(withId(R.id.popup_button)).perform(click()); + onView(withText(R.string.popup_item_1_text)).check(matches(isDisplayed())).perform(click()); + + onView(withId(R.id.text_menu_result)).check(matches(withText(R.string.popup_item_1_text))); + } + + public void testContextMenu() { + onView(withText(R.string.context_item_2_text)).check(doesNotExist()); + onView(withId(R.id.text_context_menu)).perform(longClick()); + onView(withText(R.string.context_item_2_text)).check(matches(isDisplayed())).perform(click()); + + onView(withId(R.id.text_menu_result)).check(matches(withText(R.string.context_item_2_text))); + } + + public void testOptionMenu() { + onView(withText(R.string.options_item_3_text)).check(doesNotExist()); + onView(isRoot()).perform(pressMenuKey()); + onView(withText(R.string.options_item_3_text)).check(matches(isDisplayed())).perform(click()); + + onView(withId(R.id.text_menu_result)).check(matches(withText(R.string.options_item_3_text))); + } +} diff --git a/espresso/espresso-sample/src/androidTest/java/com/google/android/apps/common/testing/ui/testapp/MultipleWindowTest.java b/espresso/espresso-sample/src/androidTest/java/com/google/android/apps/common/testing/ui/testapp/MultipleWindowTest.java new file mode 100644 index 0000000..23c3ea3 --- /dev/null +++ b/espresso/espresso-sample/src/androidTest/java/com/google/android/apps/common/testing/ui/testapp/MultipleWindowTest.java @@ -0,0 +1,122 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.testapp; + +import static com.google.android.apps.common.testing.ui.espresso.Espresso.onData; +import static com.google.android.apps.common.testing.ui.espresso.Espresso.onView; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.clearText; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.click; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.scrollTo; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.typeText; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.typeTextIntoFocusedView; +import static com.google.android.apps.common.testing.ui.espresso.assertion.ViewAssertions.matches; +import static com.google.android.apps.common.testing.ui.espresso.matcher.RootMatchers.withDecorView; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withId; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; + +import android.os.Build; +import android.test.ActivityInstrumentationTestCase2; +import android.test.suitebuilder.annotation.LargeTest; + +/** + * Demonstrates dealing with multiple windows. + * + * Espresso provides the ability to switch the default window matcher used in both onView and onData + * interactions. + * + * @see com.google.android.apps.common.testing.ui.espresso.Espresso#onView + * @see com.google.android.apps.common.testing.ui.espresso.Espresso#onData + */ +@LargeTest +public class MultipleWindowTest extends ActivityInstrumentationTestCase2<SendActivity> { + + @SuppressWarnings("deprecation") + public MultipleWindowTest() { + // This constructor was deprecated - but we want to support lower API levels. + super("com.google.android.apps.common.testing.ui.testapp", SendActivity.class); + } + + @Override + public void setUp() throws Exception { + super.setUp(); + // Espresso will not launch our activity for us, we must launch it via getActivity(). + getActivity(); + } + + public void testInteractionsWithAutoCompletePopup() { + if (Build.VERSION.SDK_INT < 10) { + // Froyo's AutoCompleteTextBox is broken - do not bother testing with it. + return; + } + // Android's Window system allows multiple view hierarchies to layer on top of each other. + // + // A real world analogy would be an overhead projector with multiple transparencies placed + // on top of each other. Each Window is a transparency, and what is drawn on top of this + // transparency is the view hierarchy. + // + // By default Espresso uses a heuristic to guess which Window you intend to interact with. + // This heuristic is normally 'good enough' however if you want to interact with a Window + // that it does not select then you'll have to swap in your own root window matcher. + + + // Initially we only have 1 window, but by typing into the auto complete text view another + // window will be layered on top of the screen. Espresso ignore's this layer because it is + // not connected to the keyboard/ime. + onView(withId(R.id.auto_complete_text_view)) + .perform(scrollTo()) + .perform(typeText("So")); + + // As you can see, we continue typing oblivious to the new window on the screen. + // At the moment there should be 2 completions (South China Sea and Southern Ocean) + // Lets narrow that down to 1 completion. + onView(withId(R.id.auto_complete_text_view)) + .perform(typeTextIntoFocusedView("uth ")); + + // Now we may want to explicitly tap on a completion. We must override Espresso's + // default window selection heuristic with our own. + onView(withText("South China Sea")) + .inRoot(withDecorView(not(is(getActivity().getWindow().getDecorView())))) + .perform(click()); + + // And by clicking on the auto complete term, the text should be filled in. + onView(withId(R.id.auto_complete_text_view)) + .check(matches(withText("South China Sea"))); + + + // NB: The autocompletion box is implemented with a ListView, so the preferred way + // to interact with it is onData(). We can use inRoot here too! + onView(withId(R.id.auto_complete_text_view)) + .perform(clearText()) + .perform(typeText("S")); + + // Which is useful because some of the completions may not be part of the View Hierarchy + // unless you scroll around the list. + onData(allOf(instanceOf(String.class), is("Baltic Sea"))) + .inRoot(withDecorView(not(is(getActivity().getWindow().getDecorView())))) + .perform(click()); + + onView(withId(R.id.auto_complete_text_view)) + .check(matches(withText("Baltic Sea"))); + } + +} + + diff --git a/espresso/espresso-sample/src/androidTest/java/com/google/android/apps/common/testing/ui/testapp/ScrollToTest.java b/espresso/espresso-sample/src/androidTest/java/com/google/android/apps/common/testing/ui/testapp/ScrollToTest.java new file mode 100644 index 0000000..6cf7836 --- /dev/null +++ b/espresso/espresso-sample/src/androidTest/java/com/google/android/apps/common/testing/ui/testapp/ScrollToTest.java @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.testapp; + +import static com.google.android.apps.common.testing.ui.espresso.Espresso.onView; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.click; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.scrollTo; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withId; +import static org.hamcrest.Matchers.is; + +import android.test.ActivityInstrumentationTestCase2; +import android.test.suitebuilder.annotation.LargeTest; + + +/** + * Demonstrates the usage of + * {@link com.google.android.apps.common.testing.ui.espresso.action.ViewActions#scrollTo()}. + */ +@LargeTest +public class ScrollToTest extends ActivityInstrumentationTestCase2<ScrollActivity> { + + @SuppressWarnings("deprecation") + public ScrollToTest() { + // This constructor was deprecated - but we want to support lower API levels. + super("com.google.android.apps.common.testing.ui.testapp", ScrollActivity.class); + } + + @Override + public void setUp() throws Exception { + super.setUp(); + // Espresso will not launch our activity for us, we must launch it via getActivity(). + getActivity(); + } + + // You can pass more than one action to perform. This is useful if you are performing two actions + // back-to-back on the same view. + // Note - scrollTo is a no-op if the view is already displayed on the screen. + public void testScrollToInScrollView() { + onView(withId(is(R.id.bottom_left))) + .perform(scrollTo(), click()); + } +} diff --git a/espresso/espresso-sample/src/androidTest/java/com/google/android/apps/common/testing/ui/testapp/SwipeTest.java b/espresso/espresso-sample/src/androidTest/java/com/google/android/apps/common/testing/ui/testapp/SwipeTest.java new file mode 100644 index 0000000..46704ec --- /dev/null +++ b/espresso/espresso-sample/src/androidTest/java/com/google/android/apps/common/testing/ui/testapp/SwipeTest.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.testapp; + +import static com.google.android.apps.common.testing.ui.espresso.Espresso.onView; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.swipeLeft; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.swipeRight; +import static com.google.android.apps.common.testing.ui.espresso.assertion.ViewAssertions.matches; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDisplayed; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withId; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withText; + +import com.google.android.apps.common.testing.ui.espresso.action.ViewActions; + +import android.test.ActivityInstrumentationTestCase2; +import android.test.suitebuilder.annotation.LargeTest; + +/** + * Demonstrates use of {@link ViewActions#swipeLeft()} and {@link ViewActions#swipeRight()}. + */ +@LargeTest +public class SwipeTest extends ActivityInstrumentationTestCase2<ViewPagerActivity> { + + @SuppressWarnings("deprecation") + public SwipeTest() { + // This constructor was deprecated - but we want to support lower API levels. + super("com.google.android.apps.common.testing.ui.testapp", ViewPagerActivity.class); + } + + @Override + protected void setUp() throws Exception { + super.setUp(); + getActivity(); + } + + public void testSwipingThroughViews() { + // Should be on position 0 to start with. + onView(withText("Position #0")).check(matches(isDisplayed())); + + // Swipe left once. + onView(withId(R.id.pager_layout)).perform(swipeLeft()); + + // Now position 1 should be visible. + onView(withText("Position #1")).check(matches(isDisplayed())); + + // Swipe left again. + onView(withId(R.id.pager_layout)).perform(swipeLeft()); + + // Now position 2 should be visible. + onView(withText("Position #2")).check(matches(isDisplayed())); + + // Swipe left again. + onView(withId(R.id.pager_layout)).perform(swipeLeft()); + + // Position 2 should still be visible as this is the last view in the pager. + onView(withText("Position #2")).check(matches(isDisplayed())); + } + + public void testSwipingBackAndForward() { + // Should be on position 0 to start with. + onView(withText("Position #0")).check(matches(isDisplayed())); + + // Swipe left once. + onView(withId(R.id.pager_layout)).perform(swipeLeft()); + + // Now position 1 should be visible. + onView(withText("Position #1")).check(matches(isDisplayed())); + + // Swipe back to the right. + onView(withId(R.id.pager_layout)).perform(swipeRight()); + + // Now position 0 should be visible again. + onView(withText("Position #0")).check(matches(isDisplayed())); + + // Swipe right again. + onView(withId(R.id.pager_layout)).perform(swipeRight()); + + // Position 0 should still be visible as this is the first view in the pager. + onView(withText("Position #0")).check(matches(isDisplayed())); + } + +} diff --git a/espresso/espresso-sample/src/main/AndroidManifest.xml b/espresso/espresso-sample/src/main/AndroidManifest.xml new file mode 100644 index 0000000..cf8e548 --- /dev/null +++ b/espresso/espresso-sample/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2014 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + package="com.google.android.apps.common.testing.ui.testapp"> + + <uses-sdk android:minSdkVersion = "7" android:targetSdkVersion= "16"/> + + <application android:label="UI Test App" android:icon="@drawable/ic_launcher" > + <activity android:name="MainActivity" android:label="UI Test App"> + <intent-filter> + <action android:name="android.intent.action.MAIN"/> + <category android:name="android.intent.category.LAUNCHER"/> + </intent-filter> + </activity> + <activity android:name="ActionBarTestActivity" android:label="actionbar test activity" android:theme="@style/Theme.AppCompat.Light.DarkActionBar"/> + <activity android:name="SimpleActivity" android:label="simple activity"/> + <activity android:name="SendActivity" android:label="send activity"/> + <activity android:name="DisplayActivity" android:label="display activity"/> + <activity android:name="DrawerActivity" android:label="drawer activity" android:theme="@style/Theme.AppCompat.Light"/> + <activity android:name="GestureActivity" android:label="gesture activity" android:exported="true"/> + <activity android:name="ScrollActivity" android:label="scroll activity" android:exported="true"/> + <activity android:name="LongListActivity" android:label="list activity" android:exported="true"/> + <activity android:name="MenuActivity" android:label="menu activity"/> + <activity android:name="FragmentStack" android:label="fragment stack activity"/> + <activity android:name="SyncActivity" android:label="sync activity"/> + <activity android:name="SimpleWebViewActivity" android:label="web view"/> + <activity android:name="SwipeActivity" android:label="swipe activity"/> + <activity android:name="ViewPagerActivity" android:label="view pager activity"/> + </application> + + <uses-permission android:name="android.permission.CALL_PHONE"></uses-permission> +</manifest> diff --git a/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/ActionBarTestActivity.java b/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/ActionBarTestActivity.java new file mode 100644 index 0000000..5c1ac60 --- /dev/null +++ b/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/ActionBarTestActivity.java @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.testapp; + +import android.os.Bundle; +import android.support.v7.app.ActionBarActivity; +import android.support.v7.view.ActionMode; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.widget.Button; +import android.widget.TextView; + +/** + * Shows ActionBar with a lot of items to get Action overflow on large displays. Click on item + * changes text of R.id.textActionBarResult. + */ +public class ActionBarTestActivity extends ActionBarActivity { + private ActionMode mode; + private MenuInflater inflater; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.actionbar_activity); + inflater = getMenuInflater(); + mode = startSupportActionMode(new TestActionMode()); + + ((Button) findViewById(R.id.show_contextual_action_bar)).setOnClickListener( + new View.OnClickListener() { + @Override + public void onClick(View v) { + mode = startSupportActionMode(new TestActionMode()); + } + }); + ((Button) findViewById(R.id.hide_contextual_action_bar)).setOnClickListener( + new View.OnClickListener() { + @Override + public void onClick(View v) { + if (mode != null) { + mode.finish(); + } + } + }); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + inflater.inflate(R.menu.actionbar_context_actions, menu); + return super.onCreateOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem menu) { + setResult(menu.getTitle()); + return true; + } + + private void setResult(CharSequence result) { + TextView text = (TextView) findViewById(R.id.text_action_bar_result); + text.setText(result); + } + + private final class TestActionMode implements ActionMode.Callback { + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + inflater.inflate(R.menu.actionbar_activity_actions, menu); + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + return false; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + setResult(item.getTitle()); + return true; + } + + @Override + public void onDestroyActionMode(ActionMode mode) {} + } +} diff --git a/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/DelegatingEditText.java b/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/DelegatingEditText.java new file mode 100644 index 0000000..dace49c --- /dev/null +++ b/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/DelegatingEditText.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.testapp; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.inputmethod.InputMethodManager; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.TextView.OnEditorActionListener; + +/** + * Custom edit text widget. + */ +public class DelegatingEditText extends LinearLayout { + + private final EditText delegateEditText; + private final TextView messageView; + private final Context mContext; + + public DelegatingEditText(Context context) { + this(context, null); + } + + public DelegatingEditText(Context context, AttributeSet attrs) { + super(context, attrs); + setOrientation(VERTICAL); + mContext = context; + LayoutInflater inflater = LayoutInflater.from(context); + inflater.inflate(R.layout.delegating_edit_text, this, /* attachToRoot */ true); + messageView = (TextView) findViewById(R.id.edit_text_message); + delegateEditText = (EditText) findViewById(R.id.delegate_edit_text); + delegateEditText.setOnEditorActionListener(new OnEditorActionListener() { + @Override + public boolean onEditorAction(TextView v, int actionCode, KeyEvent event) { + messageView.setText("typed: " + delegateEditText.getText()); + messageView.setVisibility(View.VISIBLE); + InputMethodManager imm = + (InputMethodManager) mContext.getSystemService(Context.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(delegateEditText.getWindowToken(), 0); + return true; + } + }); + } +} diff --git a/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/DisplayActivity.java b/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/DisplayActivity.java new file mode 100644 index 0000000..d05ee00 --- /dev/null +++ b/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/DisplayActivity.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.testapp; + + +import android.app.Activity; +import android.os.Bundle; +import android.widget.TextView; + +/** + * Simple activity used to display data received from another activity. + */ +public class DisplayActivity extends Activity { + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.display_activity); + TextView textView = (TextView) findViewById(R.id.display_data); + textView.setText(getIntent().getStringExtra(SendActivity.EXTRA_DATA)); + } +} diff --git a/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/DrawerActivity.java b/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/DrawerActivity.java new file mode 100644 index 0000000..a13f688 --- /dev/null +++ b/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/DrawerActivity.java @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.testapp; + +import android.app.Activity; +import android.content.res.Configuration; +import android.os.Bundle; +import android.support.v4.app.ActionBarDrawerToggle; +import android.support.v4.widget.DrawerLayout; +import android.view.MenuItem; +import android.view.View; +import android.widget.AdapterView; +import android.widget.AdapterView.OnItemClickListener; +import android.widget.ArrayAdapter; +import android.widget.ListAdapter; +import android.widget.ListView; +import android.widget.TextView; + +/** + * Activity to demonstrate actions on a {@link DrawerLayout}. + */ +public class DrawerActivity extends Activity { + + public static final String[] DRAWER_CONTENTS = + new String[] {"Platypus", "Wombat", "Pickle", "Badger"}; + + private ActionBarDrawerToggle drawerToggle; + private CharSequence title; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.drawer_activity); + + ListAdapter listAdapter = new ArrayAdapter<String>( + getApplicationContext(), R.layout.drawer_row, R.id.drawer_row_name, DRAWER_CONTENTS); + final DrawerLayout drawerLayout = (DrawerLayout) findViewById(R.id.drawer_layout); + ListView drawerList = (ListView) findViewById(R.id.drawer_list); + drawerList.setAdapter(listAdapter); + + final TextView textView = (TextView) findViewById(R.id.drawer_text_view); + + drawerList.setOnItemClickListener(new OnItemClickListener() { + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + textView.setText("You picked: " + DRAWER_CONTENTS[(int) id]); + drawerLayout.closeDrawers(); + } + }); + + // enable ActionBar app icon to behave as action to toggle nav drawer + // TODO(user): use compat lib for lower API levels + if (android.os.Build.VERSION.SDK_INT >= 11) { + getActionBar().setDisplayHomeAsUpEnabled(true); + getActionBar().setHomeButtonEnabled(true); + } + + title = getTitle(); + + drawerToggle = new ActionBarDrawerToggle( + this, + drawerLayout, + R.drawable.ic_drawer, + R.string.nav_drawer_open, + R.string.nav_drawer_close) { + + /** Called when a drawer has settled in a completely closed state. */ + public void onDrawerClosed(View view) { + if (android.os.Build.VERSION.SDK_INT >= 11) { + getActionBar().setTitle(title); + } + } + + /** Called when a drawer has settled in a completely open state. */ + public void onDrawerOpened(View drawerView) { + if (android.os.Build.VERSION.SDK_INT >= 11) { + getActionBar().setTitle(title); + } + } + }; + drawerLayout.setDrawerListener(drawerToggle); + } + + @Override + public void setTitle(CharSequence title) { + this.title = title; + if (android.os.Build.VERSION.SDK_INT >= 11) { + getActionBar().setTitle(title); + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + // The action bar home/up action should open or close the drawer. + // ActionBarDrawerToggle will take care of this. + if (drawerToggle.onOptionsItemSelected(item)) { + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + protected void onPostCreate(Bundle savedInstanceState) { + super.onPostCreate(savedInstanceState); + // Sync the toggle state after onRestoreInstanceState has occurred. + drawerToggle.syncState(); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + // Pass any configuration change to the drawer toggls + drawerToggle.onConfigurationChanged(newConfig); + } +} diff --git a/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/FragmentStack.java b/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/FragmentStack.java new file mode 100644 index 0000000..e89ce27 --- /dev/null +++ b/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/FragmentStack.java @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.testapp; + +import android.app.Activity; +import android.app.Fragment; +import android.app.FragmentTransaction; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.TextView; + +/** + * Displays a counter using fragments. + */ +public class FragmentStack extends Activity { + int stackLevel = 1; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.fragment_stack); + + // Watch for button clicks. + Button button = (Button) findViewById(R.id.new_fragment); + button.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + addFragmentToStack(); + } + }); + + if (savedInstanceState == null) { + // Do first time initialization -- add initial fragment. + Fragment newFragment = CountingFragment.newInstance(stackLevel); + FragmentTransaction ft = getFragmentManager().beginTransaction(); + ft.add(R.id.simple_fragment, newFragment).commit(); + } else { + stackLevel = savedInstanceState.getInt("level"); + } + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putInt("level", stackLevel); + } + + + void addFragmentToStack() { + stackLevel++; + + // Instantiate a new fragment. + Fragment newFragment = CountingFragment.newInstance(stackLevel); + + // Add the fragment to the activity, pushing this transaction + // on to the back stack. + FragmentTransaction ft = getFragmentManager().beginTransaction(); + ft.replace(R.id.simple_fragment, newFragment); + ft.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN); + ft.addToBackStack(null); + ft.commit(); + } + + + + /** + * A fragment that displays a number. + */ + public static class CountingFragment extends Fragment { + int counter; + + /** + * Create a new instance of CountingFragment, providing "num" + * as an argument. + */ + static CountingFragment newInstance(int num) { + CountingFragment f = new CountingFragment(); + + // Supply num input as an argument. + Bundle args = new Bundle(); + args.putInt("num", num); + f.setArguments(args); + + return f; + } + + /** + * When creating, retrieve this instance's number from its arguments. + */ + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + counter = getArguments() != null ? getArguments().getInt("num") : 1; + } + + /** + * The Fragment's UI is just a simple text view showing its + * instance number. + */ + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + TextView text = new TextView(getActivity()); + text.setText("Fragment #" + counter); + return text; + } + } + +} diff --git a/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/GestureActivity.java b/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/GestureActivity.java new file mode 100644 index 0000000..b2cbc32 --- /dev/null +++ b/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/GestureActivity.java @@ -0,0 +1,224 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.testapp; + +import com.google.common.collect.Lists; + +import android.app.Activity; +import android.os.Bundle; +import android.os.SystemClock; +import android.util.Log; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.View; + +import java.util.List; + +/** + * Displays a large touchable area and logs the events it receives. + */ +public class GestureActivity extends Activity { + private static final String TAG = GestureActivity.class.getSimpleName(); + + + private View gestureArea; + private List<MotionEvent> downEvents = Lists.newArrayList(); + private List<MotionEvent> scrollEvents = Lists.newArrayList(); + private List<MotionEvent> longPressEvents = Lists.newArrayList(); + private List<MotionEvent> showPresses = Lists.newArrayList(); + private List<MotionEvent> singleTaps = Lists.newArrayList(); + private List<MotionEvent> confirmedSingleTaps = Lists.newArrayList(); + private List<MotionEvent> doubleTapEvents = Lists.newArrayList(); + private List<MotionEvent> doubleTaps = Lists.newArrayList(); + + public void clearDownEvents() { + downEvents.clear(); + } + + public void clearScrollEvents() { + scrollEvents.clear(); + } + + public void clearLongPressEvents() { + longPressEvents.clear(); + } + + public void clearShowPresses() { + showPresses.clear(); + } + + public void clearSingleTaps() { + singleTaps.clear(); + } + + public void clearConfirmedSingleTaps() { + confirmedSingleTaps.clear(); + } + + public void clearDoubleTapEvents() { + doubleTapEvents.clear(); + } + + public void clearDoubleTaps() { + doubleTaps.clear(); + } + + public List<MotionEvent> getDownEvents() { + return Lists.newArrayList(downEvents); + } + + public List<MotionEvent> getScrollEvents() { + return Lists.newArrayList(scrollEvents); + } + + public List<MotionEvent> getLongPressEvents() { + return Lists.newArrayList(longPressEvents); + } + + public List<MotionEvent> getShowPresses() { + return Lists.newArrayList(showPresses); + } + + public List<MotionEvent> getSingleTaps() { + return Lists.newArrayList(singleTaps); + } + + public List<MotionEvent> getConfirmedSingleTaps() { + return Lists.newArrayList(confirmedSingleTaps); + } + + public List<MotionEvent> getDoubleTapEvents() { + return Lists.newArrayList(doubleTapEvents); + } + + public List<MotionEvent> getDoubleTaps() { + return Lists.newArrayList(doubleTaps); + } + + @Override + public void onCreate(Bundle icicle) { + super.onCreate(icicle); + setContentView(R.layout.gesture_activity); + gestureArea = findViewById(R.id.gesture_area); + final GestureDetector simpleDetector = new GestureDetector(this, new GestureListener()); + simpleDetector.setIsLongpressEnabled(true); + simpleDetector.setOnDoubleTapListener(new DoubleTapListener()); + gestureArea.setOnTouchListener(new View.OnTouchListener() { + @Override + public boolean onTouch(View v, MotionEvent m) { + boolean res = simpleDetector.onTouchEvent(m); + if (-1 != touchDelay) { + Log.i(TAG, "sleeping for: " + touchDelay); + SystemClock.sleep(touchDelay); + + } + return res; + } + }); + } + + private volatile long touchDelay = -1; + + public void setTouchDelay(long touchDelay) { + this.touchDelay = touchDelay; + } + + public void areaClicked(@SuppressWarnings("unused") View v) { + Log.v(TAG, "onClick called!"); + } + + private class DoubleTapListener implements GestureDetector.OnDoubleTapListener { + @Override + public boolean onDoubleTap(MotionEvent e) { + doubleTaps.add(MotionEvent.obtain(e)); + Log.v(TAG, "onDoubleTap: " + e); + setVisible(R.id.text_double_click); + return false; + } + + @Override + public boolean onDoubleTapEvent(MotionEvent e) { + doubleTapEvents.add(MotionEvent.obtain(e)); + Log.v(TAG, "onDoubleTapEvent: " + e); + return false; + } + + @Override + public boolean onSingleTapConfirmed(MotionEvent e) { + confirmedSingleTaps.add(MotionEvent.obtain(e)); + Log.v(TAG, "onSingleTapConfirmed: " + e); + return false; + } + } + + private class GestureListener implements GestureDetector.OnGestureListener { + @Override + public boolean onDown(MotionEvent e) { + downEvents.add(MotionEvent.obtain(e)); + Log.v(TAG, "Down: " + e); + return false; + } + + @Override + public boolean onSingleTapUp(MotionEvent e) { + singleTaps.add(MotionEvent.obtain(e)); + Log.v(TAG, "on single tap: " + e); + setVisible(R.id.text_click); + return false; + } + + @Override + public boolean onScroll(MotionEvent e1, MotionEvent e2, float distX, float distY) { + scrollEvents.add(MotionEvent.obtain(e1)); + scrollEvents.add(MotionEvent.obtain(e2)); + Log.v(TAG, "Scroll: e1: " + e1 + " e2: " + e2 + " distX: " + distX + " distY: " + distY); + setVisible(R.id.text_swipe); + return false; + } + + @Override + public void onShowPress(MotionEvent e) { + showPresses.add(MotionEvent.obtain(e)); + Log.v(TAG, "ShowPress: " + e); + } + + @Override + public void onLongPress(MotionEvent e) { + longPressEvents.add(MotionEvent.obtain(e)); + Log.v(TAG, "LongPress: " + e); + setVisible(R.id.text_long_click); + } + + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, float veloX, float veloY) { + Log.v(TAG, "Fling: e1: " + e1 + " e2: " + e2 + " veloX: " + veloX + " veloY: " + veloY); + return false; + } + } + + private void setVisible(int id) { + hideAll(); + findViewById(id).setVisibility(View.VISIBLE); + } + + private void hideAll() { + findViewById(R.id.text_click).setVisibility(View.GONE); + findViewById(R.id.text_long_click).setVisibility(View.GONE); + findViewById(R.id.text_swipe).setVisibility(View.GONE); + findViewById(R.id.text_double_click).setVisibility(View.GONE); + } +} diff --git a/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/LongListActivity.java b/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/LongListActivity.java new file mode 100644 index 0000000..300c0ee --- /dev/null +++ b/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/LongListActivity.java @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.testapp; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; + +import android.app.Activity; +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.ListAdapter; +import android.widget.ListView; +import android.widget.SimpleAdapter; +import android.widget.TextView; + +import java.util.List; +import java.util.Map; + +/** + * An activity displaying a long list. + */ +public class LongListActivity extends Activity { + + @VisibleForTesting + public static final String STR = "STR"; + @VisibleForTesting + public static final String LEN = "LEN"; + @VisibleForTesting + public static final String FOOTER = "FOOTER"; + + private List<Map<String, Object>> data = Lists.newArrayList(); + private LayoutInflater layoutInflater; + + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + populateData(); + setContentView(R.layout.list_activity); + ((TextView) findViewById(R.id.selection_row_value)).setText(""); + ((TextView) findViewById(R.id.selection_column_value)).setText(""); + + ListView listView = (ListView) findViewById(R.id.list); + String[] from = new String[] {STR, LEN}; + int[] to = new int[] {R.id.item_content, R.id.item_size}; + layoutInflater = (LayoutInflater) getSystemService(Context.LAYOUT_INFLATER_SERVICE); + + ListAdapter adapter = new SimpleAdapter(this, data, R.layout.list_item, from, to) { + @Override + public View getView(final int position, View convertView, ViewGroup parent) { + if (convertView == null) { + convertView = layoutInflater.inflate(R.layout.list_item, null); + } + + TextView textViewOne = (TextView) convertView.findViewById(R.id.item_content); + if (textViewOne != null) { + textViewOne.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + ((TextView) findViewById(R.id.selection_row_value)).setText(String.valueOf(position)); + ((TextView) findViewById(R.id.selection_column_value)).setText("1"); + } + }); + } + + TextView textViewTwo = (TextView) convertView.findViewById(R.id.item_size); + if (textViewTwo != null) { + textViewTwo.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + ((TextView) findViewById(R.id.selection_row_value)).setText(String.valueOf(position)); + ((TextView) findViewById(R.id.selection_column_value)).setText("2"); + } + }); + } + return super.getView(position, convertView, parent); + } + }; + + View footerView = layoutInflater.inflate(R.layout.list_item, listView, false); + ((TextView) footerView.findViewById(R.id.item_content)).setText("count:"); + ((TextView) footerView.findViewById(R.id.item_size)).setText(String.valueOf(data.size())); + listView.addFooterView(footerView, FOOTER, true); + + listView.setAdapter(adapter); + listView.setOnItemClickListener(new AdapterView.OnItemClickListener() { + @Override + public void onItemClick( + AdapterView<?> unusedParent, View clickedView, int position, long id) { + ((TextView) findViewById(R.id.selection_column_value)).setText(""); + ((TextView) findViewById(R.id.selection_row_value)).setText(String.valueOf(position)); + } + }); + } + + public Map<String, Object> makeItem(int forRow) { + Map<String, Object> dataRow = Maps.newHashMap(); + dataRow.put(STR, "item: " + forRow); + dataRow.put(LEN, ((String) dataRow.get(STR)).length()); + return dataRow; + } + + private void populateData() { + for (int i = 0; i < 100; i++) { + data.add(makeItem(i)); + } + } + +} diff --git a/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/MainActivity.java b/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/MainActivity.java new file mode 100644 index 0000000..c5ad762 --- /dev/null +++ b/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/MainActivity.java @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.testapp; + +import android.app.ListActivity; +import android.content.Intent; +import android.content.pm.ActivityInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.content.pm.PackageManager.NameNotFoundException; +import android.os.Bundle; +import android.util.Log; +import android.view.View; +import android.widget.ListView; +import android.widget.SimpleAdapter; + +import java.text.Collator; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Displays a list with all available activities. + */ +public class MainActivity extends ListActivity { + private static final String TAG = MainActivity.class.getSimpleName(); + + private static final Comparator<Map<String, Object>> sDisplayNameComparator = + new Comparator<Map<String, Object>>() { + private final Collator collator = Collator.getInstance(); + + @Override + public int compare(Map<String, Object> map1, Map<String, Object> map2) { + return collator.compare(map1.get("title"), map2.get("title")); + } + }; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setListAdapter(new SimpleAdapter( + this, getData(), android.R.layout.simple_list_item_1, new String[] {"title"}, + new int[] {android.R.id.text1})); + getListView().setTextFilterEnabled(true); + } + + private List<Map<String, Object>> getData() { + List<Map<String, Object>> data = new ArrayList<Map<String, Object>>(); + + PackageInfo info = null; + try { + info = getPackageManager().getPackageInfo(getPackageName(), PackageManager.GET_ACTIVITIES); + } catch (NameNotFoundException e) { + Log.e(TAG, "Packageinfo not found in: " + getPackageName()); + } + + if (null == info) { + return data; + } else { + for (ActivityInfo activityInfo : info.activities) { + + if (!activityInfo.name.equals(getComponentName().getClassName())) { + String[] label = activityInfo.name.split(getPackageName() + "."); + addItem(data, label[1], + createActivityIntent(activityInfo.applicationInfo.packageName, activityInfo.name)); + } + } + } + + Collections.sort(data, sDisplayNameComparator); + return data; + } + + private Intent createActivityIntent(String pkg, String componentName) { + Intent result = new Intent(); + result.setClassName(pkg, componentName); + return result; + } + + private void addItem(List<Map<String, Object>> data, String name, Intent intent) { + Map<String, Object> temp = new HashMap<String, Object>(); + temp.put("title", name); + temp.put("intent", intent); + data.add(temp); + } + + @Override + protected void onListItemClick(ListView listView, View view, int position, long id) { + Map<?, ?> map = (Map<?, ?>) listView.getItemAtPosition(position); + + Intent intent = (Intent) map.get("intent"); + startActivity(intent); + } +} diff --git a/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/MenuActivity.java b/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/MenuActivity.java new file mode 100644 index 0000000..e893cea --- /dev/null +++ b/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/MenuActivity.java @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.testapp; + +import android.app.Activity; +import android.os.Build; +import android.os.Bundle; +import android.view.ContextMenu; +import android.view.ContextMenu.ContextMenuInfo; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.widget.PopupMenu; +import android.widget.PopupMenu.OnMenuItemClickListener; +import android.widget.TextView; + +/** + * Shows MenuActivity with Options menu, Context menu and Popup menu. Click on a menu item changes + * text of R.id.textMenuResult. + */ +public class MenuActivity extends Activity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.menu_activity); + registerForContextMenu(findViewById(R.id.text_context_menu)); + } + + @Override + public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) { + super.onCreateContextMenu(menu, v, menuInfo); + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.contextmenu, menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + TextView text = (TextView) findViewById(R.id.text_menu_result); + text.setText(item.getTitle()); + return true; + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.optionsmenu, menu); + return true; + } + + @Override + public boolean onContextItemSelected(MenuItem item) { + TextView text = (TextView) findViewById(R.id.text_menu_result); + text.setText(item.getTitle()); + return true; + } + + public void showPopup(View view) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.HONEYCOMB) { + TextView text = (TextView) findViewById(R.id.text_menu_result); + text.setText("Not supported in API " + Build.VERSION.SDK_INT); + } else { + PopupMenu popup = new PopupMenu(this, view); + popup.setOnMenuItemClickListener(new PopupMenuListener()); + popup.getMenuInflater().inflate(R.menu.popupmenu, popup.getMenu()); + popup.show(); + } + } + + @Override + public boolean onMenuItemSelected(int featureId, MenuItem item) { + return super.onMenuItemSelected(featureId, item); + } + + private class PopupMenuListener implements OnMenuItemClickListener { + @Override + public boolean onMenuItemClick(MenuItem item) { + TextView text = (TextView) findViewById(R.id.text_menu_result); + text.setText(item.getTitle()); + return true; + } + } +} diff --git a/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/ScrollActivity.java b/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/ScrollActivity.java new file mode 100644 index 0000000..864fb23 --- /dev/null +++ b/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/ScrollActivity.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.testapp; + +import android.app.Activity; +import android.os.Bundle; + +/** + * An activity displaying various scroll views. + */ +public class ScrollActivity extends Activity { + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.scroll_activity); + } +} diff --git a/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/SendActivity.java b/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/SendActivity.java new file mode 100644 index 0000000..fe472e7 --- /dev/null +++ b/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/SendActivity.java @@ -0,0 +1,219 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.testapp; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.view.Gravity; +import android.view.KeyEvent; +import android.view.MenuInflater; +import android.view.View; +import android.view.View.OnKeyListener; +import android.view.ViewGroup.LayoutParams; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputMethodManager; +import android.widget.ArrayAdapter; +import android.widget.AutoCompleteTextView; +import android.widget.EditText; +import android.widget.PopupMenu; +import android.widget.PopupWindow; +import android.widget.TextView; + +/** + * Simple activity used for validating intent sending and UI behavior. + */ +public class SendActivity extends Activity { + + private static final int PICK_CONTACT_REQUEST = 1; // The request code + static final String EXTRA_DATA = "com.google.android.apps.common.testing.ui.testapp.DATA"; + static final int PICK_CONTACT = 100; + private PopupWindow popupWindow; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.send_activity); + + EditText editText = (EditText) findViewById(R.id.enter_data_edit_text); + editText.setOnKeyListener(new OnKeyListener() { + + @Override + public boolean onKey(View view, int keyCode, KeyEvent event) { + if ((event.getAction() == KeyEvent.ACTION_DOWN) && + (keyCode == KeyEvent.KEYCODE_ENTER)) { + EditText editText = (EditText) view; + TextView responseText = (TextView) findViewById(R.id.enter_data_response_text); + responseText.setText(editText.getText()); + return true; + } else { + return false; + } + } + }); + + final EditText searchBox = (EditText) findViewById(R.id.search_box); + searchBox.setOnEditorActionListener(new TextView.OnEditorActionListener() { + @Override + public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { + if (actionId == EditorInfo.IME_ACTION_SEARCH) { + TextView result = (TextView) findViewById(R.id.search_result); + result.setText(getString(R.string.searching_for_label) + " " + v.getText()); + result.setVisibility(View.VISIBLE); + InputMethodManager imm = + (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(searchBox.getWindowToken(), 0); + return true; + } + return false; + } + }); + AutoCompleteTextView autoComplete = (AutoCompleteTextView) findViewById( + R.id.auto_complete_text_view); + String [] completions = new String[] { + "Pacific Ocean", "Atlantic Ocean", "Indian Ocean", "Southern Ocean", "Artic Ocean", + "Mediterranean Sea", "Caribbean Sea", "South China Sea", "Bering Sea", + "Gulf of Mexico", "Okhotsk Sea", "East China Sea", "Hudson Bay", "Japan Sea", + "Andaman Sea", "North Sea", "Red Sea", "Baltic Sea" }; + ArrayAdapter<String> adapter = new ArrayAdapter<String>(this, + android.R.layout.simple_dropdown_item_1line, + completions); + autoComplete.setAdapter(adapter); + } + + /** Called when user clicks the Send button */ + public void sendData(@SuppressWarnings("unused") View view) { + Intent intent = new Intent(this, DisplayActivity.class); + EditText editText = (EditText) findViewById(R.id.send_data_edit_text); + intent.putExtra(EXTRA_DATA, editText.getText().toString()); + startActivity(intent); + } + + public void sendDataToCall(@SuppressWarnings("unused") View view) { + Intent intentToCall = new Intent(Intent.ACTION_CALL); + EditText editText = (EditText) findViewById(R.id.send_data_to_call_edit_text); + String number = editText.getText().toString(); + intentToCall.setData(Uri.parse("tel:" + number)); + startActivity(intentToCall); + } + + public void sendDataToBrowser(@SuppressWarnings("unused") View view) { + EditText editText = (EditText) findViewById(R.id.send_data_to_browser_edit_text); + String url = editText.getText().toString(); + Intent intentToBrowser = new Intent(Intent.ACTION_VIEW); + intentToBrowser.setData(Uri.parse(url)); + intentToBrowser.addCategory(Intent.CATEGORY_BROWSABLE); + intentToBrowser.putExtra("key1", "value1"); + intentToBrowser.putExtra("key2", "value2"); + startActivity(intentToBrowser); + } + + public void sendMessage(@SuppressWarnings("unused") View view) { + Intent sendIntent = new Intent(); + EditText editText = (EditText) findViewById(R.id.send_data_to_message_edit_text); + sendIntent.setAction(Intent.ACTION_SEND); + sendIntent.putExtra(Intent.EXTRA_TEXT, editText.getText().toString()); + sendIntent.setType("text/plain"); + startActivity(sendIntent); + } + + public void clickToMarket(@SuppressWarnings("unused") View view) { + Intent marketIntent = new Intent(Intent.ACTION_VIEW); + EditText editText = (EditText) findViewById(R.id.send_to_market_data); + marketIntent.setData(Uri.parse( + "market://details?id=" + editText.getText().toString())); + startActivity(marketIntent); + } + + public void clickToGesture(@SuppressWarnings("unused") View view) { + startActivity(new Intent(this, GestureActivity.class)); + } + + public void clickToScroll(@SuppressWarnings("unused") View view) { + startActivity(new Intent(this, ScrollActivity.class)); + } + + public void clickToList(@SuppressWarnings("unused") View view) { + startActivity(new Intent(this, LongListActivity.class)); + } + + public boolean showDialog(@SuppressWarnings("unused") View view) { + new AlertDialog.Builder(this) + .setTitle(R.string.dialog_title) + .setMessage(R.string.dialog_message) + .setNeutralButton("Fine", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int choice) { + dialog.dismiss(); + } + }) + .show(); + return true; + } + + public boolean showPopupView(View view) { + View content = getLayoutInflater().inflate(R.layout.popup_window, null, false); + popupWindow = new PopupWindow(content, LayoutParams.WRAP_CONTENT, + LayoutParams.WRAP_CONTENT, true); + content.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + popupWindow.dismiss(); + } + }); + + popupWindow.showAtLocation(view, Gravity.CENTER, 0, 0); + + return true; + } + + public boolean showPopupMenu(View view) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.HONEYCOMB) { + return false; + } + + PopupMenu popup = new PopupMenu(this, view); + MenuInflater inflater = popup.getMenuInflater(); + inflater.inflate(R.menu.popup_menu, popup.getMenu()); + popup.show(); + return true; + } + + public void pickContact(@SuppressWarnings("unused") View view) { + Intent pickContactIntent = new Intent(Intent.ACTION_PICK, Uri.parse("content://contacts")); + pickContactIntent.setType(Phone.CONTENT_TYPE); // Show user only contacts w/ phone numbers + startActivityForResult(pickContactIntent, PICK_CONTACT_REQUEST); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + if (requestCode == PICK_CONTACT_REQUEST) { + if (resultCode == RESULT_OK) { + // TODO(user): hook this up for real as shown in this example: + // http://developer.android.com/training/basics/intents/result.html + TextView textView = (TextView) findViewById(R.id.phone_number); + textView.setText(data.getExtras().getString("phone")); + } + } + } +} diff --git a/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/SimpleActivity.java b/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/SimpleActivity.java new file mode 100644 index 0000000..8af2747 --- /dev/null +++ b/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/SimpleActivity.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.testapp; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.view.View; +import android.widget.AdapterView; +import android.widget.AdapterView.OnItemSelectedListener; +import android.widget.ArrayAdapter; +import android.widget.EditText; +import android.widget.Spinner; +import android.widget.TextView; + +/** + * Simple activity used to demonstrate a simple Espresso test. + */ +public class SimpleActivity extends Activity implements OnItemSelectedListener{ + + static final String EXTRA_DATA = "com.google.android.apps.common.testing.ui.testapp.DATA"; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.simple_activity); + + Spinner spinner = (Spinner) findViewById(R.id.spinner_simple); + ArrayAdapter<CharSequence> adapter = ArrayAdapter.createFromResource(this, + R.array.spinner_array, android.R.layout.simple_spinner_item); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + spinner.setAdapter(adapter); + spinner.setOnItemSelectedListener(this); + } + + public void simpleButtonClicked(View view) { + TextView textView = (TextView) findViewById(R.id.text_simple); + String message = "Hello Espresso!"; + textView.setText(message); + } + + /** Called when user clicks the Send button */ + public void sendButtonClicked(@SuppressWarnings("unused") View view) { + Intent intent = new Intent(this, DisplayActivity.class); + EditText editText = (EditText) findViewById(R.id.sendtext_simple); + intent.putExtra(EXTRA_DATA, editText.getText().toString()); + startActivity(intent); + } + + public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) { + TextView textView = (TextView) findViewById(R.id.spinnertext_simple); + textView.setText(String.format("One %s a day!", parent.getItemAtPosition(pos))); + } + + public void onNothingSelected(AdapterView<?> parent) { + } +} + diff --git a/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/SimplePagerAdapter.java b/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/SimplePagerAdapter.java new file mode 100644 index 0000000..42d9cf4 --- /dev/null +++ b/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/SimplePagerAdapter.java @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.testapp; + +import android.graphics.Color; +import android.support.v4.view.PagerAdapter; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +class SimplePagerAdapter extends PagerAdapter { + + private static final int[] COLORS = { + Color.BLUE, + Color.RED, + Color.YELLOW, + }; + + private static final int NUM_PAGES = COLORS.length; + + @Override + public int getCount() { + return NUM_PAGES; + } + + @Override + public boolean isViewFromObject(View view, Object object) { + return view == object; + } + + @Override + public int getItemPosition(Object object) { + return ((ViewGroup) ((View) object).getParent()).indexOfChild((View) object); + } + + @Override + public Object instantiateItem(ViewGroup container, int position) { + LayoutInflater inflater = LayoutInflater.from(container.getContext()); + View view = inflater.inflate(R.layout.pager_view, null); + ((TextView) view.findViewById(R.id.pager_content)).setText("Position #" + position); + view.setBackgroundColor(COLORS[position]); + container.addView(view); + return view; + } + + @Override + public void destroyItem(ViewGroup container, int position, Object object) { + container.removeView((View) object); + } +} diff --git a/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/SimpleWebViewActivity.java b/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/SimpleWebViewActivity.java new file mode 100644 index 0000000..9c844af --- /dev/null +++ b/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/SimpleWebViewActivity.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.testapp; + +import android.app.Activity; +import android.os.Bundle; +import android.webkit.WebSettings; +import android.webkit.WebView; + +/** + * One big web view to play with. + */ +public class SimpleWebViewActivity extends Activity { + @Override + public void onCreate(Bundle icicle) { + super.onCreate(icicle); + WebView mainWebView = new WebView(this); + setContentView(mainWebView); + mainWebView.loadData( + "<html>" + + "<script>document.was_clicked = false</script>" + + "<body> " + + "<button style='height:1000px;width:1000px;' onclick='document.was_clicked = true'> " + + "I'm a button</button>" + + "</body> " + + "</html>", "text/html", null); + WebSettings settings = mainWebView.getSettings(); + settings.setJavaScriptEnabled(true); + } +} diff --git a/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/SwipeActivity.java b/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/SwipeActivity.java new file mode 100644 index 0000000..93d1c18 --- /dev/null +++ b/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/SwipeActivity.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.testapp; + +import android.app.Activity; +import android.os.Bundle; +import android.support.v4.view.ViewPager; + +/** + * Activity to test swipe interactions. + */ +public class SwipeActivity extends Activity { + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.swipe_activity); + + ((ViewPager) findViewById(R.id.small_pager)).setAdapter(new SimplePagerAdapter()); + ((ViewPager) findViewById(R.id.overlapped_pager)).setAdapter(new SimplePagerAdapter()); + } + +} + diff --git a/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/SyncActivity.java b/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/SyncActivity.java new file mode 100644 index 0000000..6702390 --- /dev/null +++ b/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/SyncActivity.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.testapp; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.annotations.VisibleForTesting; + +import android.app.Activity; +import android.os.Bundle; +import android.os.SystemClock; +import android.view.View; +import android.widget.TextView; + +import java.util.Random; + +/** + * Displays "hello world" with a random delay of 2 to 7s after the user clicks on a button. This is + * used to demonstrate how Espresso can synchronize with any part of your application, which may + * cause the application state to be unstable (e.g. a network call). + */ +public class SyncActivity extends Activity { + + /** + * A server that returns a hello world string + */ + public interface HelloWorldServer { + String getHelloWorld(); + } + + private HelloWorldServer helloWorldServer; + private TextView statusTextView; + + @Override + protected void onCreate(Bundle icicle) { + super.onCreate(icicle); + + setContentView(R.layout.sync_activity); + + statusTextView = checkNotNull(((TextView) findViewById(R.id.status_text))); + + setHelloWorldServer(new HelloWorldServer() { + @Override + public String getHelloWorld() { + Random rand = new Random(); + SystemClock.sleep(rand.nextInt(5000) + 2000); + return getString(R.string.hello_world); + } + }); + } + + public void onRequestButtonClick(@SuppressWarnings("unused") View view) { + Thread t = new Thread() { + @Override + public void run() { + final String helloworld = helloWorldServer.getHelloWorld(); + runOnUiThread(new Runnable() { + @Override + public void run() { + setStatus(helloworld); + } + }); + } + }; + t.start(); + } + + private void setStatus(String text) { + statusTextView.setText(text); + } + + @VisibleForTesting + public HelloWorldServer getHelloWorldServer() { + return helloWorldServer; + } + + @VisibleForTesting + public void setHelloWorldServer(HelloWorldServer helloWorldServer) { + this.helloWorldServer = helloWorldServer; + } +} diff --git a/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/ViewPagerActivity.java b/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/ViewPagerActivity.java new file mode 100644 index 0000000..ec92db8 --- /dev/null +++ b/espresso/espresso-sample/src/main/java/com/google/android/apps/common/testing/ui/testapp/ViewPagerActivity.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.testapp; + +import android.app.Activity; +import android.os.Bundle; +import android.support.v4.view.ViewPager; + +/** + * Activity to demonstrate actions on a {@link ViewPager}. + */ +public class ViewPagerActivity extends Activity { + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.pager_activity); + + final ViewPager pager = (ViewPager) findViewById(R.id.pager_layout); + pager.setAdapter(new SimplePagerAdapter()); + } + +} + diff --git a/espresso/espresso-sample/src/main/res/drawable-hdpi/ic_action_calendar.png b/espresso/espresso-sample/src/main/res/drawable-hdpi/ic_action_calendar.png Binary files differnew file mode 100644 index 0000000..c7bd88b --- /dev/null +++ b/espresso/espresso-sample/src/main/res/drawable-hdpi/ic_action_calendar.png diff --git a/espresso/espresso-sample/src/main/res/drawable-hdpi/ic_action_key.png b/espresso/espresso-sample/src/main/res/drawable-hdpi/ic_action_key.png Binary files differnew file mode 100644 index 0000000..ff876a0 --- /dev/null +++ b/espresso/espresso-sample/src/main/res/drawable-hdpi/ic_action_key.png diff --git a/espresso/espresso-sample/src/main/res/drawable-hdpi/ic_action_lock.png b/espresso/espresso-sample/src/main/res/drawable-hdpi/ic_action_lock.png Binary files differnew file mode 100644 index 0000000..1c80686 --- /dev/null +++ b/espresso/espresso-sample/src/main/res/drawable-hdpi/ic_action_lock.png diff --git a/espresso/espresso-sample/src/main/res/drawable-hdpi/ic_action_save.png b/espresso/espresso-sample/src/main/res/drawable-hdpi/ic_action_save.png Binary files differnew file mode 100644 index 0000000..827355d --- /dev/null +++ b/espresso/espresso-sample/src/main/res/drawable-hdpi/ic_action_save.png diff --git a/espresso/espresso-sample/src/main/res/drawable-hdpi/ic_action_search.png b/espresso/espresso-sample/src/main/res/drawable-hdpi/ic_action_search.png Binary files differnew file mode 100644 index 0000000..b826566 --- /dev/null +++ b/espresso/espresso-sample/src/main/res/drawable-hdpi/ic_action_search.png diff --git a/espresso/espresso-sample/src/main/res/drawable-hdpi/ic_action_world.png b/espresso/espresso-sample/src/main/res/drawable-hdpi/ic_action_world.png Binary files differnew file mode 100644 index 0000000..612a5f2 --- /dev/null +++ b/espresso/espresso-sample/src/main/res/drawable-hdpi/ic_action_world.png diff --git a/espresso/espresso-sample/src/main/res/drawable-hdpi/ic_drawer.png b/espresso/espresso-sample/src/main/res/drawable-hdpi/ic_drawer.png Binary files differnew file mode 100644 index 0000000..9691a6c --- /dev/null +++ b/espresso/espresso-sample/src/main/res/drawable-hdpi/ic_drawer.png diff --git a/espresso/espresso-sample/src/main/res/drawable-hdpi/ic_launcher.png b/espresso/espresso-sample/src/main/res/drawable-hdpi/ic_launcher.png Binary files differnew file mode 100644 index 0000000..c1615e0 --- /dev/null +++ b/espresso/espresso-sample/src/main/res/drawable-hdpi/ic_launcher.png diff --git a/espresso/espresso-sample/src/main/res/drawable-ldpi/ic_action_calendar.png b/espresso/espresso-sample/src/main/res/drawable-ldpi/ic_action_calendar.png Binary files differnew file mode 100644 index 0000000..37a9d7a --- /dev/null +++ b/espresso/espresso-sample/src/main/res/drawable-ldpi/ic_action_calendar.png diff --git a/espresso/espresso-sample/src/main/res/drawable-ldpi/ic_action_key.png b/espresso/espresso-sample/src/main/res/drawable-ldpi/ic_action_key.png Binary files differnew file mode 100644 index 0000000..8628a15 --- /dev/null +++ b/espresso/espresso-sample/src/main/res/drawable-ldpi/ic_action_key.png diff --git a/espresso/espresso-sample/src/main/res/drawable-ldpi/ic_action_lock.png b/espresso/espresso-sample/src/main/res/drawable-ldpi/ic_action_lock.png Binary files differnew file mode 100644 index 0000000..a29abbb --- /dev/null +++ b/espresso/espresso-sample/src/main/res/drawable-ldpi/ic_action_lock.png diff --git a/espresso/espresso-sample/src/main/res/drawable-ldpi/ic_action_save.png b/espresso/espresso-sample/src/main/res/drawable-ldpi/ic_action_save.png Binary files differnew file mode 100644 index 0000000..a51c100 --- /dev/null +++ b/espresso/espresso-sample/src/main/res/drawable-ldpi/ic_action_save.png diff --git a/espresso/espresso-sample/src/main/res/drawable-ldpi/ic_action_search.png b/espresso/espresso-sample/src/main/res/drawable-ldpi/ic_action_search.png Binary files differnew file mode 100644 index 0000000..1b4aac6 --- /dev/null +++ b/espresso/espresso-sample/src/main/res/drawable-ldpi/ic_action_search.png diff --git a/espresso/espresso-sample/src/main/res/drawable-ldpi/ic_action_world.png b/espresso/espresso-sample/src/main/res/drawable-ldpi/ic_action_world.png Binary files differnew file mode 100644 index 0000000..c27143c --- /dev/null +++ b/espresso/espresso-sample/src/main/res/drawable-ldpi/ic_action_world.png diff --git a/espresso/espresso-sample/src/main/res/drawable-ldpi/ic_drawer.png b/espresso/espresso-sample/src/main/res/drawable-ldpi/ic_drawer.png Binary files differnew file mode 100644 index 0000000..9691a6c --- /dev/null +++ b/espresso/espresso-sample/src/main/res/drawable-ldpi/ic_drawer.png diff --git a/espresso/espresso-sample/src/main/res/drawable-ldpi/ic_launcher.png b/espresso/espresso-sample/src/main/res/drawable-ldpi/ic_launcher.png Binary files differnew file mode 100644 index 0000000..110987a --- /dev/null +++ b/espresso/espresso-sample/src/main/res/drawable-ldpi/ic_launcher.png diff --git a/espresso/espresso-sample/src/main/res/drawable-mdpi/ic_action_calendar.png b/espresso/espresso-sample/src/main/res/drawable-mdpi/ic_action_calendar.png Binary files differnew file mode 100644 index 0000000..7cb5f27 --- /dev/null +++ b/espresso/espresso-sample/src/main/res/drawable-mdpi/ic_action_calendar.png diff --git a/espresso/espresso-sample/src/main/res/drawable-mdpi/ic_action_key.png b/espresso/espresso-sample/src/main/res/drawable-mdpi/ic_action_key.png Binary files differnew file mode 100644 index 0000000..6bb6ff3 --- /dev/null +++ b/espresso/espresso-sample/src/main/res/drawable-mdpi/ic_action_key.png diff --git a/espresso/espresso-sample/src/main/res/drawable-mdpi/ic_action_lock.png b/espresso/espresso-sample/src/main/res/drawable-mdpi/ic_action_lock.png Binary files differnew file mode 100644 index 0000000..ee9dea0 --- /dev/null +++ b/espresso/espresso-sample/src/main/res/drawable-mdpi/ic_action_lock.png diff --git a/espresso/espresso-sample/src/main/res/drawable-mdpi/ic_action_save.png b/espresso/espresso-sample/src/main/res/drawable-mdpi/ic_action_save.png Binary files differnew file mode 100644 index 0000000..eebcf21 --- /dev/null +++ b/espresso/espresso-sample/src/main/res/drawable-mdpi/ic_action_save.png diff --git a/espresso/espresso-sample/src/main/res/drawable-mdpi/ic_action_search.png b/espresso/espresso-sample/src/main/res/drawable-mdpi/ic_action_search.png Binary files differnew file mode 100644 index 0000000..3516c9e --- /dev/null +++ b/espresso/espresso-sample/src/main/res/drawable-mdpi/ic_action_search.png diff --git a/espresso/espresso-sample/src/main/res/drawable-mdpi/ic_action_world.png b/espresso/espresso-sample/src/main/res/drawable-mdpi/ic_action_world.png Binary files differnew file mode 100644 index 0000000..f7e8dcf --- /dev/null +++ b/espresso/espresso-sample/src/main/res/drawable-mdpi/ic_action_world.png diff --git a/espresso/espresso-sample/src/main/res/drawable-mdpi/ic_drawer.png b/espresso/espresso-sample/src/main/res/drawable-mdpi/ic_drawer.png Binary files differnew file mode 100644 index 0000000..2190a93 --- /dev/null +++ b/espresso/espresso-sample/src/main/res/drawable-mdpi/ic_drawer.png diff --git a/espresso/espresso-sample/src/main/res/drawable-mdpi/ic_launcher.png b/espresso/espresso-sample/src/main/res/drawable-mdpi/ic_launcher.png Binary files differnew file mode 100644 index 0000000..90f091c --- /dev/null +++ b/espresso/espresso-sample/src/main/res/drawable-mdpi/ic_launcher.png diff --git a/espresso/espresso-sample/src/main/res/drawable-xhdpi/ic_action_calendar.png b/espresso/espresso-sample/src/main/res/drawable-xhdpi/ic_action_calendar.png Binary files differnew file mode 100644 index 0000000..d34b110 --- /dev/null +++ b/espresso/espresso-sample/src/main/res/drawable-xhdpi/ic_action_calendar.png diff --git a/espresso/espresso-sample/src/main/res/drawable-xhdpi/ic_action_key.png b/espresso/espresso-sample/src/main/res/drawable-xhdpi/ic_action_key.png Binary files differnew file mode 100644 index 0000000..31c6756 --- /dev/null +++ b/espresso/espresso-sample/src/main/res/drawable-xhdpi/ic_action_key.png diff --git a/espresso/espresso-sample/src/main/res/drawable-xhdpi/ic_action_lock.png b/espresso/espresso-sample/src/main/res/drawable-xhdpi/ic_action_lock.png Binary files differnew file mode 100644 index 0000000..63797e2 --- /dev/null +++ b/espresso/espresso-sample/src/main/res/drawable-xhdpi/ic_action_lock.png diff --git a/espresso/espresso-sample/src/main/res/drawable-xhdpi/ic_action_save.png b/espresso/espresso-sample/src/main/res/drawable-xhdpi/ic_action_save.png Binary files differnew file mode 100644 index 0000000..2e7c579 --- /dev/null +++ b/espresso/espresso-sample/src/main/res/drawable-xhdpi/ic_action_save.png diff --git a/espresso/espresso-sample/src/main/res/drawable-xhdpi/ic_action_search.png b/espresso/espresso-sample/src/main/res/drawable-xhdpi/ic_action_search.png Binary files differnew file mode 100644 index 0000000..3539eab --- /dev/null +++ b/espresso/espresso-sample/src/main/res/drawable-xhdpi/ic_action_search.png diff --git a/espresso/espresso-sample/src/main/res/drawable-xhdpi/ic_action_world.png b/espresso/espresso-sample/src/main/res/drawable-xhdpi/ic_action_world.png Binary files differnew file mode 100644 index 0000000..e1b21d3 --- /dev/null +++ b/espresso/espresso-sample/src/main/res/drawable-xhdpi/ic_action_world.png diff --git a/espresso/espresso-sample/src/main/res/drawable-xhdpi/ic_drawer.png b/espresso/espresso-sample/src/main/res/drawable-xhdpi/ic_drawer.png Binary files differnew file mode 100644 index 0000000..e2dd13a --- /dev/null +++ b/espresso/espresso-sample/src/main/res/drawable-xhdpi/ic_drawer.png diff --git a/espresso/espresso-sample/src/main/res/drawable-xhdpi/ic_launcher.png b/espresso/espresso-sample/src/main/res/drawable-xhdpi/ic_launcher.png Binary files differnew file mode 100644 index 0000000..0f6604b --- /dev/null +++ b/espresso/espresso-sample/src/main/res/drawable-xhdpi/ic_launcher.png diff --git a/espresso/espresso-sample/src/main/res/layout/actionbar_activity.xml b/espresso/espresso-sample/src/main/res/layout/actionbar_activity.xml new file mode 100644 index 0000000..41fa6fa --- /dev/null +++ b/espresso/espresso-sample/src/main/res/layout/actionbar_activity.xml @@ -0,0 +1,47 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2014 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="fill_parent" + android:layout_height="fill_parent" + android:orientation="vertical" + android:padding="20dip" > + + <Button + android:id="@+id/show_contextual_action_bar" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_horizontal" + android:text="@string/text_show" /> + + <Button + android:id="@+id/hide_contextual_action_bar" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_horizontal" + android:text="@string/text_hide" /> + + <TextView + android:id="@+id/text_action_bar_result" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_horizontal" + android:layout_marginTop="20dp" + android:text="@string/text_empty" + android:textAppearance="?android:attr/textAppearanceSmall" /> + +</LinearLayout> diff --git a/espresso/espresso-sample/src/main/res/layout/delegating_edit_text.xml b/espresso/espresso-sample/src/main/res/layout/delegating_edit_text.xml new file mode 100644 index 0000000..8d4cb33 --- /dev/null +++ b/espresso/espresso-sample/src/main/res/layout/delegating_edit_text.xml @@ -0,0 +1,34 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2014 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<merge xmlns:android="http://schemas.android.com/apk/res/android" > + + <EditText + android:id="@+id/delegate_edit_text" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:inputType="text" + android:singleLine="true" /> + + <TextView + android:id="@+id/edit_text_message" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:singleLine="false" + android:visibility="gone" /> + +</merge> diff --git a/espresso/espresso-sample/src/main/res/layout/display_activity.xml b/espresso/espresso-sample/src/main/res/layout/display_activity.xml new file mode 100644 index 0000000..5781524 --- /dev/null +++ b/espresso/espresso-sample/src/main/res/layout/display_activity.xml @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2014 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<!-- XML for screen for displaying data received from another activity. --> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_alignParentRight="true" + android:layout_alignParentTop="true" + android:orientation="vertical" > + + <TextView + android:id="@+id/display_title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:text="@string/display_title" + android:textAppearance="?android:attr/textAppearanceMedium" /> + + <TextView + android:id="@+id/display_data" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:text="@string/display_data" + android:textAppearance="?android:attr/textAppearanceSmall" /> + +</LinearLayout> diff --git a/espresso/espresso-sample/src/main/res/layout/drawer_activity.xml b/espresso/espresso-sample/src/main/res/layout/drawer_activity.xml new file mode 100644 index 0000000..ea5532d --- /dev/null +++ b/espresso/espresso-sample/src/main/res/layout/drawer_activity.xml @@ -0,0 +1,44 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2014 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<android.support.v4.widget.DrawerLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/drawer_layout" + android:layout_width="fill_parent" + android:layout_height="fill_parent" > + + <!-- The main content view --> + <FrameLayout + android:layout_width="match_parent" + android:layout_height="match_parent" > + <TextView + android:id="@+id/drawer_text_view" + android:layout_width="wrap_content" + android:layout_height="wrap_content" /> + </FrameLayout> + + <ListView + android:id="@+id/drawer_list" + android:layout_width="240dp" + android:layout_height="match_parent" + android:layout_gravity="start" + android:choiceMode="singleChoice" + android:divider="@android:color/transparent" + android:dividerHeight="0dp" + android:background="#111" /> + +</android.support.v4.widget.DrawerLayout> diff --git a/espresso/espresso-sample/src/main/res/layout/drawer_row.xml b/espresso/espresso-sample/src/main/res/layout/drawer_row.xml new file mode 100644 index 0000000..f56a688 --- /dev/null +++ b/espresso/espresso-sample/src/main/res/layout/drawer_row.xml @@ -0,0 +1,31 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2014 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_gravity="fill_horizontal|center_vertical" + android:gravity="fill_horizontal" + android:minHeight="70dip" + android:orientation="horizontal" > + + <TextView android:id="@+id/drawer_row_name" + android:layout_width="wrap_content" + android:layout_height="match_parent" + android:layout_alignParentLeft="true" + android:gravity="left|center_vertical"/> +</RelativeLayout> diff --git a/espresso/espresso-sample/src/main/res/layout/fragment_stack.xml b/espresso/espresso-sample/src/main/res/layout/fragment_stack.xml new file mode 100644 index 0000000..0861f87 --- /dev/null +++ b/espresso/espresso-sample/src/main/res/layout/fragment_stack.xml @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2014 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:gravity="center_horizontal" + android:orientation="vertical" + android:padding="4dip" > + + <FrameLayout + android:id="@+id/simple_fragment" + android:layout_width="match_parent" + android:layout_height="0px" + android:layout_weight="1" > + </FrameLayout> + + <Button + android:id="@+id/new_fragment" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_weight="0" + android:text="Create New Fragment" > + + <requestFocus /> + </Button> + +</LinearLayout> diff --git a/espresso/espresso-sample/src/main/res/layout/gesture_activity.xml b/espresso/espresso-sample/src/main/res/layout/gesture_activity.xml new file mode 100644 index 0000000..d2bf3f7 --- /dev/null +++ b/espresso/espresso-sample/src/main/res/layout/gesture_activity.xml @@ -0,0 +1,80 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2014 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<!-- XML for screen providing ability to test different clicks and gestures. --> + +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="fill_parent" + android:layout_height="fill_parent" + android:fillViewport="true" + android:orientation="vertical" > + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" > + + <TextView + android:id="@+id/text_click" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_weight="1" + android:gravity="center" + android:text="@string/text_click" + android:textAppearance="?android:attr/textAppearanceMedium" + android:visibility="gone" /> + + <TextView + android:id="@+id/text_long_click" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_weight="1" + android:gravity="center" + android:text="@string/text_long_click" + android:textAppearance="?android:attr/textAppearanceMedium" + android:visibility="gone" /> + + <TextView + android:id="@+id/text_swipe" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_weight="1" + android:gravity="center" + android:text="@string/text_swipe" + android:textAppearance="?android:attr/textAppearanceMedium" + android:visibility="gone" /> + + <TextView + android:id="@+id/text_double_click" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_weight="1" + android:gravity="center" + android:text="@string/text_double_click" + android:textAppearance="?android:attr/textAppearanceMedium" + android:visibility="gone" /> + </LinearLayout> + + <View + android:id="@+id/gesture_area" + android:layout_width="fill_parent" + android:layout_height="0dp" + android:layout_weight="1" + android:clickable="true" + android:gravity="top" + android:onClick="areaClicked" /> + +</LinearLayout> diff --git a/espresso/espresso-sample/src/main/res/layout/list_activity.xml b/espresso/espresso-sample/src/main/res/layout/list_activity.xml new file mode 100644 index 0000000..20dfc5c --- /dev/null +++ b/espresso/espresso-sample/src/main/res/layout/list_activity.xml @@ -0,0 +1,66 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2014 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<!-- XML for a screen with a list view. --> + +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical" + android:paddingBottom="48dp" + android:paddingTop="48dp" > + + <LinearLayout + android:layout_width="fill_parent" + android:layout_height="24dp" > + + <TextView + android:id="@+id/selection_row" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/row_label" /> + + <TextView + android:id="@+id/selection_row_value" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginLeft="12dp" /> + </LinearLayout> + + <LinearLayout + android:layout_width="fill_parent" + android:layout_height="24dp" > + + <TextView + android:id="@+id/selection_column" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/column_label" /> + + <TextView + android:id="@+id/selection_column_value" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginLeft="12dp" /> + </LinearLayout> + + <ListView + android:id="@+id/list" + android:layout_width="match_parent" + android:layout_height="wrap_content" /> + +</LinearLayout> diff --git a/espresso/espresso-sample/src/main/res/layout/list_item.xml b/espresso/espresso-sample/src/main/res/layout/list_item.xml new file mode 100644 index 0000000..d1cf1d9 --- /dev/null +++ b/espresso/espresso-sample/src/main/res/layout/list_item.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2014 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="fill_parent" + android:layout_height="fill_parent" > + + <TextView + android:id="@+id/item_content" + android:layout_width="wrap_content" + android:layout_height="wrap_content" /> + + <TextView + android:id="@+id/item_size" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginLeft="50dp" /> + +</LinearLayout> diff --git a/espresso/espresso-sample/src/main/res/layout/menu_activity.xml b/espresso/espresso-sample/src/main/res/layout/menu_activity.xml new file mode 100644 index 0000000..f47e76b --- /dev/null +++ b/espresso/espresso-sample/src/main/res/layout/menu_activity.xml @@ -0,0 +1,62 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2014 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="fill_parent" + android:layout_height="fill_parent" + android:orientation="vertical" + android:padding="20dip" > + + <LinearLayout + android:layout_width="fill_parent" + android:layout_height="wrap_content" + android:gravity="center_horizontal" + android:orientation="horizontal" > + + <Button + android:id="@+id/popup_button" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginRight="10dp" + android:onClick="showPopup" + android:text="Click here for popup menu!" /> + </LinearLayout> + + <LinearLayout + android:layout_width="fill_parent" + android:layout_height="wrap_content" + android:gravity="center_horizontal" + android:orientation="horizontal" > + + <TextView + android:id="@+id/text_context_menu" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="long-click here for context menu!" + android:textAppearance="?android:attr/textAppearanceMedium" /> + </LinearLayout> + + <TextView + android:id="@+id/text_menu_result" + android:layout_width="fill_parent" + android:layout_height="wrap_content" + android:layout_marginTop="20dp" + android:gravity="center" + android:text="@string/text_empty" + android:textAppearance="?android:attr/textAppearanceSmall" /> + +</LinearLayout> diff --git a/espresso/espresso-sample/src/main/res/layout/pager_activity.xml b/espresso/espresso-sample/src/main/res/layout/pager_activity.xml new file mode 100644 index 0000000..015b2fb --- /dev/null +++ b/espresso/espresso-sample/src/main/res/layout/pager_activity.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2014 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<android.support.v4.view.ViewPager xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/pager_layout" + android:layout_width="fill_parent" + android:layout_height="fill_parent" > + +</android.support.v4.view.ViewPager> diff --git a/espresso/espresso-sample/src/main/res/layout/pager_view.xml b/espresso/espresso-sample/src/main/res/layout/pager_view.xml new file mode 100644 index 0000000..0e8a802 --- /dev/null +++ b/espresso/espresso-sample/src/main/res/layout/pager_view.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2014 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="fill_parent" + android:layout_height="fill_parent" > + + <TextView + android:id="@+id/pager_content" + android:layout_width="fill_parent" + android:layout_height="fill_parent" + android:gravity="center" /> + +</LinearLayout> diff --git a/espresso/espresso-sample/src/main/res/layout/popup_window.xml b/espresso/espresso-sample/src/main/res/layout/popup_window.xml new file mode 100644 index 0000000..f596ecb --- /dev/null +++ b/espresso/espresso-sample/src/main/res/layout/popup_window.xml @@ -0,0 +1,39 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2014 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<!-- XML for screen providing ability to enter text and send some intents. --> + +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="vertical" > + + <TextView + android:id="@+id/popup_title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:text="@string/popup_title" + android:textAppearance="?android:attr/textAppearanceLarge" /> + + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:text="@string/popup_window_text" /> + +</LinearLayout> diff --git a/espresso/espresso-sample/src/main/res/layout/scroll_activity.xml b/espresso/espresso-sample/src/main/res/layout/scroll_activity.xml new file mode 100644 index 0000000..ecfee52 --- /dev/null +++ b/espresso/espresso-sample/src/main/res/layout/scroll_activity.xml @@ -0,0 +1,93 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2014 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<!-- XML for screen that holds various scroll views. --> + +<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="fill_parent" + android:layout_height="fill_parent" + android:fillViewport="true" + android:orientation="vertical" > + + <LinearLayout + android:layout_width="fill_parent" + android:layout_height="wrap_content" + android:orientation="vertical" > + + <TextView + android:id="@+id/top_left" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:text="top_left" /> + + <ScrollView + android:layout_width="fill_parent" + android:layout_height="50dp" + android:layout_marginTop="10dp" + android:background="#FFDDDDDD" + android:fillViewport="true" + android:orientation="vertical" > + + <LinearLayout + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="vertical" > + + <TextView + android:id="@+id/double_scroll" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="200dp" + android:text="double_scroll" + android:textColor="#000000" /> + </LinearLayout> + </ScrollView> + + <!-- Keep this on bottom to test scrolling to views that are not showing. --> + <!-- Huge top margin to guarantee this being out of view on large screen layout. --> + + <HorizontalScrollView + android:layout_width="fill_parent" + android:layout_height="50dp" + android:layout_marginTop="3000dp" + android:background="#FFDDDDDD" > + + <LinearLayout + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="horizontal" > + + <TextView + android:id="@+id/bottom_left" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="bottom_left" + android:textColor="#000000" /> + + <TextView + android:id="@+id/bottom_right" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginLeft="3000dp" + android:text="bottom_right" + android:textColor="#000000" /> + </LinearLayout> + </HorizontalScrollView> + </LinearLayout> + +</ScrollView> diff --git a/espresso/espresso-sample/src/main/res/layout/send_activity.xml b/espresso/espresso-sample/src/main/res/layout/send_activity.xml new file mode 100644 index 0000000..2e67143 --- /dev/null +++ b/espresso/espresso-sample/src/main/res/layout/send_activity.xml @@ -0,0 +1,313 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2014 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<!-- + XML for screen providing ability to enter text, send some intents, + switch to gesture activity and test scroll down action. +--> + +<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="fill_parent" + android:layout_height="fill_parent" + android:fillViewport="true" + android:orientation="vertical" > + + <LinearLayout + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="vertical" > + + <TextView + android:id="@+id/send_title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:clickable="true" + android:onClick="sendData" + android:text="@string/send_title" + android:textAppearance="?android:attr/textAppearanceLarge" /> + + <EditText + android:id="@+id/send_data_edit_text" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:ems="10" + android:hint="@string/send_hint" /> + + <Button + android:id="@+id/send_button" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:onClick="sendData" + android:text="@string/button_send" /> + + <EditText + android:id="@+id/enter_data_edit_text" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:ems="10" + android:hint="@string/enter_hint" /> + + <TextView + android:id="@+id/enter_data_response_text" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:text="" + android:textAppearance="?android:attr/textAppearanceLarge" /> + + <TextView + android:id="@+id/call" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:text="@string/send_intent_to_call" + android:textAppearance="?android:attr/textAppearanceLarge" /> + + <EditText + android:id="@+id/send_data_to_call_edit_text" + android:layout_width="229dp" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:ems="10" + android:hint="@string/send_hint_for_call" /> + + <Button + android:id="@+id/send_to_call_button" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:onClick="sendDataToCall" + android:text="@string/button_call" /> + + <TextView + android:id="@+id/send_data_message" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:text="@string/send_message" + android:textAppearance="?android:attr/textAppearanceLarge" /> + + <EditText + android:id="@+id/send_data_to_message_edit_text" + android:layout_width="290dp" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:ems="10" + android:hint="@string/send_hint" + android:text="@string/send_data_to_message_edit_text" /> + + <Button + android:id="@+id/send_message_button" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:onClick="sendMessage" + android:text="@string/button_to_message" /> + + <TextView + android:id="@+id/goto_browser" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:text="@string/send_intent_to_browser" + android:textAppearance="?android:attr/textAppearanceLarge" /> + + <EditText + android:id="@+id/send_data_to_browser_edit_text" + android:layout_width="290dp" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:ems="10" + android:hint="@string/send_hint" /> + + <Button + android:id="@+id/send_to_browser_button" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:onClick="sendDataToBrowser" + android:text="@string/button_to_browser" /> + + <TextView + android:id="@+id/pick_contact_title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:text="@string/pick_title" + android:textAppearance="?android:attr/textAppearanceLarge" /> + + <Button + android:id="@+id/pick_button" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:onClick="pickContact" + android:text="@string/button_pick" /> + + <TextView + android:id="@+id/phone_number" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" /> + + <TextView + android:id="@+id/market" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:text="@string/send_intent_to_market" + android:textAppearance="?android:attr/textAppearanceLarge" /> + + <EditText + android:id="@+id/send_to_market_data" + android:layout_width="229dp" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:ems="10" + android:hint="@string/send_hint_to_market" /> + + <Button + android:id="@+id/send_to_market_button" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:onClick="clickToMarket" + android:text="@string/button_market" /> + + <EditText + android:id="@+id/search_box" + android:layout_width="fill_parent" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:ems="10" + android:hint="search box" + android:imeOptions="actionSearch" + android:inputType="text" /> + + <TextView + android:id="@+id/search_result" + android:layout_width="fill_parent" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:visibility="invisible" /> + + <TextView + android:id="@+id/weird_text_title" + android:layout_width="fill_parent" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:text="Delegating Edit Text" + android:textAppearance="?android:attr/textAppearanceLarge" /> + + <com.google.android.apps.common.testing.ui.testapp.DelegatingEditText + android:id="@+id/delegating_edit_text" + android:layout_width="fill_parent" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" /> + + <TextView + android:id="@+id/gesture_title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:text="@string/gesture_title" + android:textAppearance="?android:attr/textAppearanceLarge" /> + + <Button + android:id="@+id/go_to_gesture_activity" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:onClick="clickToGesture" + android:text="@string/button_gesture" /> + + <Button + android:id="@+id/scroll_button" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:onClick="clickToScroll" + android:text="@string/launch_scroll_activity" /> + + <Button + android:id="@+id/list_button" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:onClick="clickToList" + android:text="@string/launch_list_activity" /> + + <Button + android:id="@+id/make_alert_dialog" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:onClick="showDialog" + android:text="@string/make_alert_dialog_button" /> + + <Button + android:id="@+id/make_popup_menu_button" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:onClick="showPopupMenu" + android:text="@string/make_popup_menu_button" /> + + <Button + android:id="@+id/make_popup_view_button" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:onClick="showPopupView" + android:text="@string/make_popup_view_button" /> + + <AutoCompleteTextView + android:id="@+id/auto_complete_text_view" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:completionThreshold="1" + android:hint="@string/pick_water" + android:textAppearance="?android:attr/textAppearanceLarge" /> + + + <!-- Keep this on bottom to test scrolling to views that are not showing. --> + <!-- Huge top margin to guarantee this being out of view on large screen layout. --> + + <Button + android:id="@+id/bottom_send_button" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="1000dp" + android:onClick="sendData" + android:text="@string/button_send_bottom" /> + + <TextView + android:id="@+id/bottom_send_text_view" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="1000dp" + android:clickable="true" + android:onClick="sendData" + android:text="@string/send_title" /> + </LinearLayout> + +</ScrollView> diff --git a/espresso/espresso-sample/src/main/res/layout/simple_activity.xml b/espresso/espresso-sample/src/main/res/layout/simple_activity.xml new file mode 100644 index 0000000..31aa760 --- /dev/null +++ b/espresso/espresso-sample/src/main/res/layout/simple_activity.xml @@ -0,0 +1,77 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2014 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical" > + + <Spinner + android:id="@+id/spinner_simple" + android:layout_width="fill_parent" + android:layout_height="wrap_content" /> + + <TextView + android:id="@+id/spinnertext_simple" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_horizontal" + android:text="" + android:textAppearance="?android:attr/textAppearanceLarge" /> + + <View + android:layout_width="1dp" + android:layout_height="30dp" /> + + <Button + android:id="@+id/button_simple" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_horizontal" + android:onClick="simpleButtonClicked" + android:text="@string/button_simple" /> + + <TextView + android:id="@+id/text_simple" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_horizontal" + android:text="@string/text_simple" + android:textAppearance="?android:attr/textAppearanceLarge" /> + + <View + android:layout_width="1dp" + android:layout_height="30dp" /> + + <EditText + android:id="@+id/sendtext_simple" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_horizontal" + android:ems="10" + android:hint="@string/send_hint" /> + + <Button + android:id="@+id/send_simple" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_horizontal" + android:onClick="sendButtonClicked" + android:text="@string/button_send" /> + +</LinearLayout> diff --git a/espresso/espresso-sample/src/main/res/layout/swipe_activity.xml b/espresso/espresso-sample/src/main/res/layout/swipe_activity.xml new file mode 100644 index 0000000..e7bfa76 --- /dev/null +++ b/espresso/espresso-sample/src/main/res/layout/swipe_activity.xml @@ -0,0 +1,54 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2014 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical" > + + <!-- With update to rev 19, swipe does not consistently work on small pagers + b/12113054 opened to investigate why this regressed .--> + <android.support.v4.view.ViewPager + android:id="@+id/small_pager" + android:layout_width="120dp" + android:layout_height="48dp" /> + + <RelativeLayout + android:layout_width="fill_parent" + android:layout_height="200dp"> + <android.support.v4.view.ViewPager + android:id="@+id/overlapped_pager" + android:layout_width="fill_parent" + android:layout_height="fill_parent" + android:layout_alignParentTop="true" + android:layout_alignParentLeft="true"/> + <TextView + android:layout_width="50dp" + android:layout_height="fill_parent" + android:background="#CCCCCC" + android:layout_alignParentTop="true" + android:layout_alignParentLeft="true"/> + </RelativeLayout> + + <TextView + android:id="@+id/text_simple" + android:text="@string/text_simple" + android:layout_width="fill_parent" + android:layout_height="wrap_content"/> + +</LinearLayout> diff --git a/espresso/espresso-sample/src/main/res/layout/sync_activity.xml b/espresso/espresso-sample/src/main/res/layout/sync_activity.xml new file mode 100644 index 0000000..5642eee --- /dev/null +++ b/espresso/espresso-sample/src/main/res/layout/sync_activity.xml @@ -0,0 +1,38 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2014 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical" > + + <Button + android:id="@+id/request_button" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:onClick="onRequestButtonClick" + android:text="@string/request_hello_world" /> + + <TextView + android:id="@+id/status_text" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:gravity="center" + android:textAppearance="?android:attr/textAppearanceMedium" /> + +</LinearLayout> + diff --git a/espresso/espresso-sample/src/main/res/menu/actionbar_activity_actions.xml b/espresso/espresso-sample/src/main/res/menu/actionbar_activity_actions.xml new file mode 100644 index 0000000..0358ffe --- /dev/null +++ b/espresso/espresso-sample/src/main/res/menu/actionbar_activity_actions.xml @@ -0,0 +1,37 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2014 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<menu xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:yourapp="http://schemas.android.com/apk/res-auto" > + + <item + android:id="@+id/action_lock" + android:icon="@drawable/ic_action_lock" + android:title="Lock" + yourapp:showAsAction="ifRoom|withText"/> + <item + android:id="@+id/action_key" + android:icon="@drawable/ic_action_key" + android:title="Key" + yourapp:showAsAction="never"/> + <item + android:id="@+id/action_calendar" + android:icon="@drawable/ic_action_calendar" + android:title="Calendar" + yourapp:showAsAction="never"/> + +</menu> diff --git a/espresso/espresso-sample/src/main/res/menu/actionbar_context_actions.xml b/espresso/espresso-sample/src/main/res/menu/actionbar_context_actions.xml new file mode 100644 index 0000000..59233bb --- /dev/null +++ b/espresso/espresso-sample/src/main/res/menu/actionbar_context_actions.xml @@ -0,0 +1,37 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2014 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<menu xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:yourapp="http://schemas.android.com/apk/res-auto" > + + <item + android:id="@+id/action_save" + android:icon="@drawable/ic_action_save" + android:title="Save" + yourapp:showAsAction="ifRoom|withText"/> + <item + android:id="@+id/action_search" + android:icon="@drawable/ic_action_search" + android:title="Search" + yourapp:showAsAction="never"/> + <item + android:id="@+id/action_world" + android:icon="@drawable/ic_action_world" + android:title="World" + yourapp:showAsAction="never"/> + +</menu> diff --git a/espresso/espresso-sample/src/main/res/menu/contextmenu.xml b/espresso/espresso-sample/src/main/res/menu/contextmenu.xml new file mode 100644 index 0000000..5d4137d --- /dev/null +++ b/espresso/espresso-sample/src/main/res/menu/contextmenu.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2014 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<menu xmlns:android="http://schemas.android.com/apk/res/android" > + + <item + android:id="@+id/context_item1" + android:title="@string/context_item_1_text"> + </item> + <item + android:id="@+id/context_item2" + android:title="@string/context_item_2_text"> + </item> + <item + android:id="@+id/context_item3" + android:title="@string/context_item_3_text"> + </item> + +</menu> diff --git a/espresso/espresso-sample/src/main/res/menu/optionsmenu.xml b/espresso/espresso-sample/src/main/res/menu/optionsmenu.xml new file mode 100644 index 0000000..66ed1b2 --- /dev/null +++ b/espresso/espresso-sample/src/main/res/menu/optionsmenu.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2014 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<menu xmlns:android="http://schemas.android.com/apk/res/android" > + + <item + android:id="@+id/option_item1" + android:title="@string/options_item_1_text"> + </item> + <item + android:id="@+id/option_item2" + android:title="@string/options_item_2_text"> + </item> + <item + android:id="@+id/option_item3" + android:title="@string/options_item_3_text"> + </item> + +</menu> diff --git a/espresso/espresso-sample/src/main/res/menu/popup_menu.xml b/espresso/espresso-sample/src/main/res/menu/popup_menu.xml new file mode 100644 index 0000000..9bfb67f --- /dev/null +++ b/espresso/espresso-sample/src/main/res/menu/popup_menu.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2014 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<menu xmlns:android="http://schemas.android.com/apk/res/android" > + + <item + android:id="@+id/menu_item_1" + android:title="@string/item_1_text"/> + <item + android:id="@+id/menu_item_2" + android:title="@string/item_2_text"/> + +</menu> diff --git a/espresso/espresso-sample/src/main/res/menu/popupmenu.xml b/espresso/espresso-sample/src/main/res/menu/popupmenu.xml new file mode 100644 index 0000000..0dae632 --- /dev/null +++ b/espresso/espresso-sample/src/main/res/menu/popupmenu.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2014 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<menu xmlns:android="http://schemas.android.com/apk/res/android" > + + <item + android:id="@+id/popup_item1" + android:title="@string/popup_item_1_text"> + </item> + <item + android:id="@+id/popup_item2" + android:title="@string/popup_item_2_text"> + </item> + <item + android:id="@+id/popup_item3" + android:title="@string/popup_item_3_text"> + </item> + +</menu> diff --git a/espresso/espresso-sample/src/main/res/values/strings.xml b/espresso/espresso-sample/src/main/res/values/strings.xml new file mode 100644 index 0000000..e9d9ec5 --- /dev/null +++ b/espresso/espresso-sample/src/main/res/values/strings.xml @@ -0,0 +1,90 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2014 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<resources> + + <string name="button_send">Send</string> + <string name="button_send_bottom">Send Bottom</string> + <string name="button_call">Call</string> + <string name="button_to_message">SMS</string> + <string name="button_to_browser">Go To Browser</string> + <string name="button_pick">Pick</string> + <string name="button_market">Goto Market</string> + <string name="button_gesture">Go To Gesture Activity</string> + <string name="button_simple">Click Me!</string> + <string name="display_data" /> + <string name="display_title">Data from sender</string> + <string name="dialog_title">An emergency alert</string> + <string name="dialog_message">A really important message</string> + <string name="enter_hint">Type text and press Enter</string> + <string name="launch_list_activity">Launch list_activity</string> + <string name="launch_scroll_activity">Launch scroll_activity</string> + <string name="send_data_to_message_edit_text">send_data_to_message_edit_text</string> + <string name="send_hint">Enter text here</string> + <string name="send_hint_for_call">Enter number here</string> + <string name="send_title">Send internal intent with data</string> + <string name="send_intent_to_call">Enter Number To Call</string> + <string name="send_intent_to_market">Enter App Id From Market</string> + <string name="send_intent_to_browser">Enter URL you wanted to go.</string> + <string name="send_hint_to_market">Enter App id</string> + <string name="send_message">Enter Message</string> + <string name="pick_title">Pick a Contact</string> + <string name="item_1_text">Menu Item 1</string> + <string name="item_2_text">Goodbye</string> + <string name="make_alert_dialog_button">Make an alert dialog</string> + <string name="make_popup_menu_button">Make a Popup Window</string> + <string name="make_popup_view_button">Make a Popup Window</string> + <string name="popup_window_text">I am in a popup window</string> + <string name="popup_title">A popup window</string> + <string name="gesture_title">Show Gesture Activity</string> + <string name="text_click">Click</string> + <string name="text_long_click">Long Click</string> + <string name="text_swipe">Swipe</string> + <string name="text_double_click">Double Click</string> + <string name="text_empty"></string> + <string name="text_show">Show context actionbar</string> + <string name="text_hide">Hide context actionbar</string> + <string name="text_simple">Message</string> + <string name="popup_item_1_text">Popup Item 1</string> + <string name="popup_item_2_text">Popup Item 2</string> + <string name="popup_item_3_text">Popup Item 3</string> + <string name="context_item_1_text">Context Item 1</string> + <string name="context_item_2_text">Context Item 2</string> + <string name="context_item_3_text">Context Item 3</string> + <string name="options_item_1_text">Options Item 1</string> + <string name="options_item_2_text">Options Item 2</string> + <string name="options_item_3_text">Options Item 3</string> + <string name="searching_for_label">Searching for:</string> + <string name="row_label">clicked on row:</string> + <string name="column_label">clicked on column:</string> + <string name="hello_world">hello world!</string> + <string name="request_hello_world">Request hello world</string> + <string name="nav_drawer_open">Open navigation drawer</string> + <string name="pick_water">Pick a body of water</string> + <string name="nav_drawer_close">Close navigation drawer</string> + + <string-array name="spinner_array"> + <item>Espresso</item> + <item>Doppio</item> + <item>Macchiato</item> + <item>Cappuccino</item> + <item>Americano</item> + <item>Mocha</item> + <item>Late</item> + </string-array> + +</resources> diff --git a/espresso/gradle.properties b/espresso/gradle.properties new file mode 100644 index 0000000..bd11c51 --- /dev/null +++ b/espresso/gradle.properties @@ -0,0 +1,52 @@ +# +# Copyright (C) 2014 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Settings specified in this file will override any Gradle settings +# configured through the IDE. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +# Default value: -Xmx10248m -XX:MaxPermSize=256m +# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# Specifies custom SDK location +#androidCustomSdkPath=/path/to/sdk + +##SNPSHOT?? +VERSION=1.2 +GROUP_ID=com.google.android.apps.common.testing //just espresso-lib? + +POM_DESCRIPTION=A simple API for writing reliable UI tests +POM_URL= +POM_SCM_URL= +POM_SCM_CONNECTION= +POM_SCM_DEV_CONNECTION= +POM_LICENCE_NAME=The Apache Software License, Version 2.0 +POM_LICENCE_URL=http://www.apache.org/licenses/LICENSE-2.0.txt +POM_LICENCE_DIST=repo +POM_DEVELOPER_ID= +POM_DEVELOPER_NAME=The Android Open Source Project
\ No newline at end of file diff --git a/espresso/gradle/wrapper/gradle-wrapper.jar b/espresso/gradle/wrapper/gradle-wrapper.jar Binary files differnew file mode 100644 index 0000000..d5c591c --- /dev/null +++ b/espresso/gradle/wrapper/gradle-wrapper.jar diff --git a/espresso/gradle/wrapper/gradle-wrapper.properties b/espresso/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..f057df0 --- /dev/null +++ b/espresso/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Jan 24 18:15:27 SGT 2014 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=http\://services.gradle.org/distributions/gradle-1.10-all.zip diff --git a/espresso/gradlew b/espresso/gradlew new file mode 100755 index 0000000..91a7e26 --- /dev/null +++ b/espresso/gradlew @@ -0,0 +1,164 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# For Cygwin, ensure paths are in UNIX format before anything is touched. +if $cygwin ; then + [ -n "$JAVA_HOME" ] && JAVA_HOME=`cygpath --unix "$JAVA_HOME"` +fi + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >&- +APP_HOME="`pwd -P`" +cd "$SAVED" >&- + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/espresso/gradlew.bat b/espresso/gradlew.bat new file mode 100644 index 0000000..aec9973 --- /dev/null +++ b/espresso/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS=
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto init
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto init
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:init
+@rem Get command-line arguments, handling Windowz variants
+
+if not "%OS%" == "Windows_NT" goto win9xME_args
+if "%@eval[2+2]" == "4" goto 4NT_args
+
+:win9xME_args
+@rem Slurp the command line arguments.
+set CMD_LINE_ARGS=
+set _SKIP=2
+
+:win9xME_args_slurp
+if "x%~1" == "x" goto execute
+
+set CMD_LINE_ARGS=%*
+goto execute
+
+:4NT_args
+@rem Get arguments from the 4NT Shell from JP Software
+set CMD_LINE_ARGS=%$
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/espresso/idling-resource-interface/build.gradle b/espresso/idling-resource-interface/build.gradle new file mode 100644 index 0000000..24781e3 --- /dev/null +++ b/espresso/idling-resource-interface/build.gradle @@ -0,0 +1,17 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +apply plugin: 'java'
\ No newline at end of file diff --git a/espresso/idling-resource-interface/src/main/java/com/google/android/apps/common/testing/ui/espresso/IdlingResource.java b/espresso/idling-resource-interface/src/main/java/com/google/android/apps/common/testing/ui/espresso/IdlingResource.java new file mode 100644 index 0000000..0f5e839 --- /dev/null +++ b/espresso/idling-resource-interface/src/main/java/com/google/android/apps/common/testing/ui/espresso/IdlingResource.java @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.apps.common.testing.ui.espresso; + +/** + * Represents a resource of an application under test which can cause asynchronous background work + * to happen during test execution (e.g. an intent service that processes a button click). By + * default, {@link Espresso} synchronizes all view operations with the UI thread as well as + * AsyncTasks; however, it has no way of doing so with "hand-made" resources. In such cases, test + * authors can register the custom resource and {@link Espresso} will wait for the resource to + * become idle prior to executing a view operation. + * <br><br> + * <b>Important Note:</b> it is assumed that the resource stays idle most of the time. + */ +public interface IdlingResource { + + /** + * Returns the name of the resources (used for logging and idempotency of registration). + */ + public String getName(); + + /** + * Returns {@code true} if resource is currently idle. Espresso will <b>always</b> call this + * method from the main thread, therefore it should be non-blocking and return immediately. + */ + public boolean isIdleNow(); + + /** + * Registers the given {@link ResourceCallback} with the resource. Espresso will call this method: + * <ul> + * <li>with its implementation of {@link ResourceCallback} so it can be notified asynchronously + * that your resource is idle + * <li>from the main thread, but you are free to execute the callback's onTransitionToIdle from + * any thread + * <li>once (when it is initially given a reference to your IdlingResource) + * </ul> + * <br> + * You only need to call this upon transition from busy to idle - if the resource is already idle + * when the method is called invoking the call back is optional and has no significant impact. + */ + public void registerIdleTransitionCallback(ResourceCallback callback); + + /** + * Registered by an {@link IdlingResource} to notify Espresso of a transition to idle. + */ + public interface ResourceCallback { + /** + * Called when the resource goes from busy to idle. + */ + public void onTransitionToIdle(); + } +} diff --git a/espresso/libs/README b/espresso/libs/README new file mode 100644 index 0000000..b829d43 --- /dev/null +++ b/espresso/libs/README @@ -0,0 +1,18 @@ +The following outlines the license and the download location of the binary files +present in this "libs" folder. + +dagger-1.2.1: +jar: http://mvnrepository.com/artifact/com.squareup.dagger/dagger/1.2.1 +license: Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + +dagger-compiler-1.2.1: +jar: http://mvnrepository.com/artifact/com.squareup.dagger/dagger-compiler/1.2.1 +license: Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + +guava-14.0.1 +jar: http://mvnrepository.com/artifact/com.google.guava/guava/14.0.1 +license: Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + +jarjar-1.4 +jar: https://code.google.com/p/jarjar +license: Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
\ No newline at end of file diff --git a/espresso/libs/dagger-1.2.1.jar b/espresso/libs/dagger-1.2.1.jar Binary files differnew file mode 100644 index 0000000..90d9509 --- /dev/null +++ b/espresso/libs/dagger-1.2.1.jar diff --git a/espresso/libs/dagger-compiler-1.2.1.jar b/espresso/libs/dagger-compiler-1.2.1.jar Binary files differnew file mode 100644 index 0000000..f2aa56d --- /dev/null +++ b/espresso/libs/dagger-compiler-1.2.1.jar diff --git a/espresso/libs/guava-14.0.1.jar b/espresso/libs/guava-14.0.1.jar Binary files differnew file mode 100644 index 0000000..3a3d925 --- /dev/null +++ b/espresso/libs/guava-14.0.1.jar diff --git a/espresso/libs/jarjar-1.4.jar b/espresso/libs/jarjar-1.4.jar Binary files differnew file mode 100644 index 0000000..68b9db9 --- /dev/null +++ b/espresso/libs/jarjar-1.4.jar diff --git a/espresso/libs/testrunner-1.1.jar b/espresso/libs/testrunner-1.1.jar Binary files differnew file mode 100644 index 0000000..5abc79f --- /dev/null +++ b/espresso/libs/testrunner-1.1.jar diff --git a/espresso/libs/testrunner-runtime-1.1.jar b/espresso/libs/testrunner-runtime-1.1.jar Binary files differnew file mode 100644 index 0000000..17b20d5 --- /dev/null +++ b/espresso/libs/testrunner-runtime-1.1.jar diff --git a/espresso/publishLocal.gradle b/espresso/publishLocal.gradle new file mode 100644 index 0000000..91596fa --- /dev/null +++ b/espresso/publishLocal.gradle @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +apply plugin: 'maven' + +def getReleaseRepositoryUrl() { + if (hasProperty('androidSdkPath')) { + return "file:/$project.androidCustomSdkPath/extras/$project.POM_ARTIFACT_ID/m2repository" + } else { + println "No Android SDK path set. Using default m2 location, " + + "defined Maven settings.xml. Set ANDROID_HOME or set SDK location " + + "via ANDROID_SDK in gradle.properties" + } +} + +task publishLocal(type: Upload) { +//task publishLocal { +// configuration = configurations.archives +// +// repositories { +// mavenCentral() +// } + + uploadArchives { + repositories { + mavenDeployer { + + println "***** ${getReleaseRepositoryUrl()}" + repository(url: getReleaseRepositoryUrl()) + + println "***** versoin $VERSION" + pom.project { + pom.version = VERSION + pom.groupId = GROUP_ID + pom.artifactId = POM_ARTIFACT_ID + +// licenses { +// license { +// name POM_LICENCE_NAME +// url POM_LICENCE_URL +// distribution POM_LICENCE_DIST +// } +// } +// +// developers { +// developer { +// //id POM_DEVELOPER_ID +// name POM_DEVELOPER_NAME +// } +// } + } + } + } + } + +// def isReleaseBuild() { +// return VERSION.contains("SNAPSHOT") == false +// } +// signing { +// required { isReleaseBuild() && gradle.taskGraph.hasTask("uploadArchives") } +// sign configurations.archives +// } +// +// task androidJavadocs(type: Javadoc) { +// source = android.sourceSets.main.allJava +// classpath += project.files(android.getBootClasspath().join(File.pathSeparator)) +// } +// +// task androidJavadocsJar(type: Jar, dependsOn: androidJavadocs) { +// classifier = 'javadoc' +// from androidJavadocs.destinationDir +// } +// +// task androidSourcesJar(type: Jar) { +// classifier = 'sources' +// from android.sourceSets.main.allSource +// } +// +// artifacts { +// archives androidSourcesJar +// archives androidJavadocsJar +// } +} diff --git a/espresso/settings.gradle b/espresso/settings.gradle new file mode 100644 index 0000000..2a8f5f0 --- /dev/null +++ b/espresso/settings.gradle @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +include ':espresso-lib' +include ':espresso-lib-tests' + +include ':espresso-contrib' +include ':espresso-contrib-tests' + +include ':espresso-sample' + +include ':idling-resource-interface' |