TV SysUI functional tests base
This is an initial CL to add functional tests for TV SysUI
Leanback Launcher, Setting, Recents and Multiwindow.
This includes both hermetic and non-hermetic tests for
acceptance testing.
This test suite could be deployed to the testing service for
continuous test runs.
Bug: 30299617
Change-Id: If96334516843e90b1e19b0d7067e3e5e800b449d
diff --git a/libraries/base-app-helpers/src/android/platform/test/helpers/AbstractLeanbackAppHelper.java b/libraries/base-app-helpers/src/android/platform/test/helpers/AbstractLeanbackAppHelper.java
index e81bcfe..7d7b1b4 100644
--- a/libraries/base-app-helpers/src/android/platform/test/helpers/AbstractLeanbackAppHelper.java
+++ b/libraries/base-app-helpers/src/android/platform/test/helpers/AbstractLeanbackAppHelper.java
@@ -17,6 +17,7 @@
package android.platform.test.helpers;
import android.app.Instrumentation;
+import android.platform.test.helpers.exceptions.UiTimeoutException;
import android.platform.test.helpers.exceptions.UnknownUiException;
import android.support.test.launcherhelper.ILeanbackLauncherStrategy;
import android.support.test.launcherhelper.LauncherStrategyFactory;
@@ -34,10 +35,23 @@
public abstract class AbstractLeanbackAppHelper extends AbstractStandardAppHelper {
private static final String TAG = AbstractLeanbackAppHelper.class.getSimpleName();
- private static final long OPEN_SECTION_WAIT_TIME_MS = 5000;
- private static final long OPEN_SIDE_PANEL_WAIT_TIME_MS = 5000;
+ private static final long OPEN_ROW_CONTENT_WAIT_TIME_MS = 5000;
+ private static final long OPEN_HEADER_WAIT_TIME_MS = 5000;
private static final int OPEN_SIDE_PANEL_MAX_ATTEMPTS = 5;
private static final long MAIN_ACTIVITY_WAIT_TIME_MS = 250;
+ private static final long SELECT_WAIT_TIME_MS = 5000;
+
+ // The notable widget classes in Leanback Library
+ public enum Widget {
+ BROWSE_HEADERS_FRAGMENT,
+ BROWSE_ROWS_FRAGMENT,
+ DETAILS_FRAGMENT,
+ SEARCH_FRAGMENT,
+ VERTICAL_GRID_FRAGMENT,
+ GUIDED_STEP_FRAGMENT,
+ PLAYBACK_OVERLAY_FRAGMENT,
+ ERROR_FRAGMENT
+ }
protected DPadHelper mDPadHelper;
public ILeanbackLauncherStrategy mLauncherStrategy;
@@ -50,52 +64,131 @@
mDevice).getLeanbackLauncherStrategy();
}
- protected abstract BySelector getAppSelector();
-
- protected abstract BySelector getSidePanelSelector();
-
- protected abstract BySelector getSidePanelResultSelector(String sectionName);
+ /**
+ * @return {@link BySelector} describing the row headers (in the left pane) in
+ * the Browse fragment
+ */
+ protected BySelector getBrowseHeadersSelector() {
+ return By.res(getPackage(), "browse_headers").hasChild(By.selected(true));
+ }
/**
- * Selector to identify main activity for getMainActivitySelector().
- * Not every application has its main activity, so the override is optional.
+ * @return {@link BySelector} describing a row content (in the right pane) selected in
+ * the Browse fragment
+ */
+ protected BySelector getBrowseRowsSelector() {
+ return By.res(getPackage(), "row_content").hasChild(By.selected(true));
+ }
+
+ /**
+ * @return {@link BySelector} describing the Details fragment
+ */
+ protected BySelector getDetailsFragmentSelector() {
+ return By.res(getPackage(), "details_fragment");
+ }
+
+ /**
+ * @return {@link BySelector} describing the Search fragment
+ */
+ protected BySelector getSearchFragmentSelector() {
+ return By.res(getPackage(), "lb_search_frame");
+ }
+
+ /**
+ * @return {@link BySelector} describing the Vertical grid fragment
+ */
+ protected BySelector getVerticalGridFragmentSelector() {
+ return By.res(getPackage(), "grid_frame");
+ }
+
+ /**
+ * @return {@link BySelector} describing the Guided step fragment
+ */
+ protected BySelector getGuidedStepFragmentSelector() {
+ return By.res(getPackage(), "guidedactions_list");
+ }
+
+ /**
+ * @return {@link BySelector} describing the Playback overlay fragment
+ */
+ protected BySelector getPlaybackOverlayFragmentSelector() {
+ return By.res(getPackage(), "playback_controls_dock");
+ }
+
+ /**
+ * @return {@link BySelector} describing the Error fragment
+ */
+ protected BySelector getErrorFragmentSelector() {
+ return By.res(getPackage(), "error_frame");
+ }
+
+ /**
+ * @return {@link BySelector} describing the main activity (mostly the Browse fragment).
+ * Note that not every application has its main activity, so the override is optional.
*/
protected BySelector getMainActivitySelector() {
return null;
}
+ // TODO Move waitForOpen and open to AbstractStandardAppHelper
/**
- * Setup expectation: Side panel is selected on browse fragment
- *
- * Best effort attempt to go to the side panel, and open the selected section.
+ * Setup expectation: None. Waits for the application to begin running.
+ * @param timeoutMs
+ * @return true if the application is open successfully
*/
- public void openSection(String sectionName) {
- openSidePanel();
- // Section header is focused; it should not be after pressing the DPad
- selectSection(sectionName);
- mDevice.pressDPadCenter();
-
- // Test for focus change and selection result
- BySelector sectionResult = getSidePanelResultSelector(sectionName);
- if (!mDevice.wait(Until.hasObject(sectionResult), OPEN_SECTION_WAIT_TIME_MS)) {
- throw new UnknownUiException(
- String.format("Failed to find result opening section %s", sectionName));
- }
- Log.v(TAG, "Successfully opened section");
+ public boolean waitForOpen(long timeoutMs) {
+ return mDevice.wait(Until.hasObject(By.pkg(getPackage()).depth(0)), timeoutMs);
}
/**
- * Setup expectation: On navigation screen on browse fragment
+ * Setup expectation: On the launcher home screen.
+ * <p>
+ * Launches the desired application and wait for it to begin running before returning.
+ * </p>
+ * @param timeoutMs
+ */
+ public void open(long timeoutMs) {
+ open();
+ if (!waitForOpen(timeoutMs)) {
+ throw new UiTimeoutException(String.format("Timed out to open a target package %s:"
+ + " %d(ms)", getPackage(), timeoutMs));
+ }
+ }
+
+ /**
+ * Setup expectation: Side panel is selected on the Browse fragment
+ * <p>
+ * Best effort attempt to go to the row headers, and open the selected header.
+ * </p>
+ */
+ public void openHeader(String headerName) {
+ openBrowseHeaders();
+ // header is focused; it should not be after pressing the DPad
+ selectHeader(headerName);
+ mDevice.pressDPadCenter();
+
+ // Test for focus change and selection result
+ BySelector rowContent = getBrowseRowsSelector();
+ if (!mDevice.wait(Until.hasObject(rowContent), OPEN_ROW_CONTENT_WAIT_TIME_MS)) {
+ throw new UnknownUiException(
+ String.format("Failed to find row content that matches the header: %s",
+ headerName));
+ }
+ Log.v(TAG, "Successfully opened header");
+ }
+
+ /**
+ * Setup expectation: On navigation screen on the Browse fragment
*
- * Best effort attempt to open the side panel.
+ * Best effort attempt to open the row headers in the Browse fragment.
* @param onMainActivity True if it opens the side panel on app's main activity.
*/
- public void openSidePanel(boolean onMainActivity) {
+ public void openBrowseHeaders(boolean onMainActivity) {
if (onMainActivity) {
returnToMainActivity();
}
int attempts = 0;
- while (!isSidePanelSelected(OPEN_SIDE_PANEL_WAIT_TIME_MS)
+ while (!waitForBrowseHeadersSelected(OPEN_HEADER_WAIT_TIME_MS)
&& attempts++ < OPEN_SIDE_PANEL_MAX_ATTEMPTS) {
mDevice.pressDPadLeft();
}
@@ -104,8 +197,8 @@
}
}
- public void openSidePanel() {
- openSidePanel(false);
+ public void openBrowseHeaders() {
+ openBrowseHeaders(false);
}
/**
@@ -121,7 +214,7 @@
}
UiObject2 focus = container.findObject(By.focused(true));
if (focus == null) {
- throw new UnknownUiException("The container should have a focus.");
+ throw new UnknownUiException("The container should have a focused descendant.");
}
while (!focus.hasObject(target)) {
UiObject2 prev = focus;
@@ -140,6 +233,100 @@
}
/**
+ * Setup expectation: On guided fragment.
+ * <p>
+ * Best effort attempt to select a given guided action.
+ * </p>
+ */
+ public UiObject2 selectGuidedAction(String action) {
+ assertWidgetEquals(Widget.GUIDED_STEP_FRAGMENT);
+ UiObject2 container = mDevice.wait(
+ Until.findObject(
+ By.res(getPackage(), "guidedactions_list").hasChild(By.focused(true))),
+ SELECT_WAIT_TIME_MS);
+ // Search down, then up
+ BySelector selector = By.res(getPackage(), "guidedactions_item_title").text(action);
+ UiObject2 focused = select(container, selector, Direction.DOWN);
+ if (focused != null) {
+ return focused;
+ }
+ focused = select(container, selector, Direction.UP);
+ if (focused != null) {
+ return focused;
+ }
+ throw new UnknownUiException(String.format("Failed to select guided action: %s", action));
+ }
+
+ /**
+ * Setup expectation: On guided fragment. Return the string in guidance title.
+ */
+ public String getGuidanceTitleText() {
+ assertWidgetEquals(Widget.GUIDED_STEP_FRAGMENT);
+ UiObject2 object = mDevice.wait(
+ Until.findObject(By.res(getPackage(), "guidance_title")), SELECT_WAIT_TIME_MS);
+ return object.getText();
+ }
+
+ /**
+ * Setup expectation: On row fragment.
+ * @param title of the card
+ * @return UIObject2 for the focusable card that matches a given name in title
+ */
+ private UiObject2 getCardInRowByTitle(String title) {
+ assertWidgetEquals(Widget.BROWSE_ROWS_FRAGMENT);
+ return mDevice.wait(Until.findObject(
+ By.focused(true).hasDescendant(By.res(getPackage(), "title_text").text(title))),
+ SELECT_WAIT_TIME_MS);
+ }
+
+ /**
+ * Setup expectation: On row fragment.
+ * @param title of the card
+ * @return String text of content in a card that has a given name in title
+ */
+ public String getCardContentText(String title) {
+ UiObject2 card = getCardInRowByTitle(title);
+ if (card == null) {
+ throw new IllegalStateException("Failed to find a card in row content " + title);
+ }
+ return card.findObject(By.res(getPackage(), "content_text")).getText();
+ }
+
+ /**
+ * Setup expectation: On row fragment.
+ * @param title of the card
+ * @return true if it finds a card that matches a given name in title
+ */
+ public boolean hasCardInRow(String title) {
+ return (getCardInRowByTitle(title) != null);
+ }
+
+ /**
+ * Setup expectation: On row fragment.
+ * <p>
+ * Open a card that matches a given title in row content
+ * </p>
+ * @param title of the card
+ */
+ public void openCardInRow(String title) {
+ assertWidgetEquals(Widget.BROWSE_ROWS_FRAGMENT);
+ UiObject2 card = getCardInRowByTitle(title);
+ if (card == null) {
+ throw new IllegalStateException("Failed to find a card in row content " + title);
+ }
+ if (!card.isFocused()) {
+ card.click(); // move a focus
+ card = getCardInRowByTitle(title);
+ if (card == null) {
+ throw new IllegalStateException("Failed to find a card in row content " + title);
+ }
+ }
+ mDPadHelper.pressDPadCenter();
+ mDevice.wait(Until.gone(By.res(getPackage(), "title_text").text(title)),
+ SELECT_WAIT_TIME_MS);
+ }
+
+ /**
* Attempts to return to main activity with getMainActivitySelector()
* by pressing the back button repeatedly and sleeping briefly to allow for UI slowness.
*/
@@ -155,38 +342,69 @@
}
}
+ /**
+ * Setup expectation: None.
+ * <p>
+ * Asserts that a given widget provided by the Support Library is shown on TV app.
+ * </p>
+ */
+ public void assertWidgetEquals(Widget expected) {
+ if (!hasWidget(expected)) {
+ throw new UnknownUiException("No widget matches " + expected.name());
+ }
+ }
+
+ private boolean hasWidget(Widget expected) {
+ switch (expected) {
+ case BROWSE_HEADERS_FRAGMENT:
+ return mDevice.hasObject(getBrowseHeadersSelector());
+ case BROWSE_ROWS_FRAGMENT:
+ return mDevice.hasObject(getBrowseRowsSelector());
+ case DETAILS_FRAGMENT:
+ return mDevice.hasObject(getDetailsFragmentSelector());
+ case SEARCH_FRAGMENT:
+ return mDevice.hasObject(getSearchFragmentSelector());
+ case VERTICAL_GRID_FRAGMENT:
+ return mDevice.hasObject(getVerticalGridFragmentSelector());
+ case GUIDED_STEP_FRAGMENT:
+ return mDevice.hasObject(getGuidedStepFragmentSelector());
+ case PLAYBACK_OVERLAY_FRAGMENT:
+ return mDevice.hasObject(getPlaybackOverlayFragmentSelector());
+ case ERROR_FRAGMENT:
+ return mDevice.hasObject(getErrorFragmentSelector());
+ default:
+ Log.w(TAG, "Unable to find the widget in the list: " + expected.name());
+ return false;
+ }
+ }
+
@Override
public void dismissInitialDialogs() {
return;
}
- protected boolean isSidePanelSelected(long timeout) {
- UiObject2 sidePanel = mDevice.wait(Until.findObject(getSidePanelSelector()), timeout);
- if (sidePanel == null) {
- return false;
- }
- return sidePanel.hasObject(By.focused(true).minDepth(1));
+ private boolean waitForBrowseHeadersSelected(long timeoutMs) {
+ return mDevice.wait(Until.hasObject(getBrowseHeadersSelector()), timeoutMs);
}
- protected UiObject2 selectSection(String sectionName) {
+ protected UiObject2 selectHeader(String headerName) {
UiObject2 container = mDevice.wait(
- Until.findObject(getSidePanelSelector()), OPEN_SIDE_PANEL_WAIT_TIME_MS);
- BySelector section = By.clazz(".TextView").text(sectionName);
+ Until.findObject(getBrowseHeadersSelector()), OPEN_HEADER_WAIT_TIME_MS);
+ BySelector header = By.clazz(".TextView").text(headerName);
- // Wait until the section text appears at runtime. This needs to be long enough to run under
- // low bandwidth environments in the test lab.
- mDevice.wait(Until.findObject(section), 60 * 1000);
+ // Wait until the row header text appears at runtime. This needs to be long enough to run
+ // under low bandwidth environments in the test lab.
+ mDevice.wait(Until.findObject(header), 60 * 1000);
// Search up, then down
- UiObject2 focused = select(container, section, Direction.UP);
+ UiObject2 focused = select(container, header, Direction.UP);
if (focused != null) {
return focused;
}
- focused = select(container, section, Direction.DOWN);
+ focused = select(container, header, Direction.DOWN);
if (focused != null) {
return focused;
}
- throw new UnknownUiException("Failed to select section");
+ throw new UnknownUiException("Failed to select header");
}
-
}
diff --git a/libraries/base-app-helpers/src/android/platform/test/helpers/CommandHelper.java b/libraries/base-app-helpers/src/android/platform/test/helpers/CommandHelper.java
new file mode 100644
index 0000000..cc4f3df
--- /dev/null
+++ b/libraries/base-app-helpers/src/android/platform/test/helpers/CommandHelper.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright (C) 2016 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 android.platform.test.helpers;
+
+import android.app.Instrumentation;
+import android.support.test.uiautomator.UiDevice;
+import android.util.Log;
+
+import java.io.IOException;
+
+/**
+ * Utility class to execute the shell command.
+ */
+public final class CommandHelper {
+ private static final String TAG = CommandHelper.class.getSimpleName();
+
+ private UiDevice mDevice;
+
+
+ public CommandHelper(Instrumentation instrumentation) {
+ mDevice = UiDevice.getInstance(instrumentation);
+ }
+
+ public void executeAmStackMovetask(int taskId, int stackId) {
+ executeShellCommand(
+ String.format("am stack movetask %d %d true", taskId, stackId));
+ }
+
+ public String executeAmStackInfo(int stackId) {
+ return executeShellCommand(String.format("am stack info %d", stackId));
+ }
+
+ public String executeAmStackList() {
+ return executeShellCommand("am stack list");
+ }
+
+ public String executeDumpsysMediaSession() {
+ return executeShellCommand("dumpsys media_session");
+ }
+
+ public String executeGetProp(String prop) {
+ return executeShellCommand(String.format("getprop %s", prop), true);
+ }
+
+ public String executeShellCommand(String command) {
+ return executeShellCommand(command, false);
+ }
+
+ private String executeShellCommand(String command, boolean trim) {
+ try {
+ String output = mDevice.executeShellCommand(command);
+ return output.trim();
+ } catch (IOException e) {
+ // ignore
+ Log.w(TAG, String.format("The shell command failed to run: %s exception: %s",
+ command, e.getMessage()));
+ return "";
+ }
+ }
+}
diff --git a/libraries/base-app-helpers/src/android/platform/test/helpers/DPadHelper.java b/libraries/base-app-helpers/src/android/platform/test/helpers/DPadHelper.java
index 51dd12b..c3ee0d1 100644
--- a/libraries/base-app-helpers/src/android/platform/test/helpers/DPadHelper.java
+++ b/libraries/base-app-helpers/src/android/platform/test/helpers/DPadHelper.java
@@ -19,7 +19,13 @@
import android.app.Instrumentation;
import android.os.SystemClock;
import android.support.test.uiautomator.Direction;
+import android.support.test.uiautomator.EventCondition;
import android.support.test.uiautomator.UiDevice;
+import android.util.Log;
+import android.view.KeyEvent;
+
+import java.io.IOException;
+
public class DPadHelper {
@@ -40,8 +46,8 @@
return mInstance;
}
- public void pressDPad(Direction direction) {
- pressDPad(direction, 1, DPAD_DEFAULT_WAIT_TIME_MS);
+ public boolean pressDPad(Direction direction) {
+ return pressDPad(direction, 1, DPAD_DEFAULT_WAIT_TIME_MS);
}
public void pressDPad(Direction direction, long repeat) {
@@ -55,25 +61,112 @@
* @param direction the direction of the button to press.
* @param repeat the number of times to press the button.
* @param timeout the timeout for the wait.
+ * @return true if the last key simulation is successful, else return false
*/
- public void pressDPad(Direction direction, long repeat, long timeout) {
+ public boolean pressDPad(Direction direction, long repeat, long timeout) {
int iteration = 0;
+ boolean result = false;
while (iteration++ < repeat) {
switch (direction) {
case LEFT:
- mDevice.pressDPadLeft();
+ result = mDevice.pressDPadLeft();
break;
case RIGHT:
- mDevice.pressDPadRight();
+ result = mDevice.pressDPadRight();
break;
case UP:
- mDevice.pressDPadUp();
+ result = mDevice.pressDPadUp();
break;
case DOWN:
- mDevice.pressDPadDown();
+ result = mDevice.pressDPadDown();
break;
}
SystemClock.sleep(timeout);
}
+ return result;
+ }
+
+ public boolean pressDPadLeft() {
+ return mDevice.pressDPadLeft();
+ }
+
+ public boolean pressDPadRight() {
+ return mDevice.pressDPadRight();
+ }
+
+ public boolean pressDPadUp() {
+ return mDevice.pressDPadUp();
+ }
+
+ public boolean pressDPadDown() {
+ return mDevice.pressDPadDown();
+ }
+
+ public boolean pressHome() {
+ return mDevice.pressHome();
+ }
+
+ public boolean pressBack() {
+ return mDevice.pressBack();
+ }
+
+ public boolean pressDPadCenter() {
+ return mDevice.pressDPadCenter();
+ }
+
+ public boolean pressEnter() {
+ return mDevice.pressEnter();
+ }
+
+ public boolean pressPipKey() {
+ return mDevice.pressKeyCode(KeyEvent.KEYCODE_WINDOW);
+ }
+
+ public boolean pressKeyCode(int keyCode) {
+ return mDevice.pressKeyCode(keyCode);
+ }
+
+ public boolean longPressKeyCode(int keyCode) {
+ try {
+ mDevice.executeShellCommand(String.format("input keyevent --longpress %d", keyCode));
+ return true;
+ } catch (IOException e) {
+ // Ignore
+ Log.w(TAG, String.format("Failed to long press the key code: %d", keyCode));
+ return false;
+ }
+ }
+
+ /**
+ * Press the key code, and waits for the given condition to become true.
+ * @param condition
+ * @param keyCode
+ * @param timeout
+ * @param <R>
+ * @return
+ */
+ public <R> R pressKeyCodeAndWait(int keyCode, EventCondition<R> condition, long timeout) {
+ return mDevice.performActionAndWait(new KeyEventRunnable(keyCode), condition, timeout);
+ }
+
+ public <R> R pressDPadCenterAndWait(EventCondition<R> condition, long timeout) {
+ return mDevice.performActionAndWait(new KeyEventRunnable(KeyEvent.KEYCODE_DPAD_CENTER),
+ condition, timeout);
+ }
+
+ public <R> R pressEnterAndWait(EventCondition<R> condition, long timeout) {
+ return mDevice.performActionAndWait(new KeyEventRunnable(KeyEvent.KEYCODE_ENTER),
+ condition, timeout);
+ }
+
+ private class KeyEventRunnable implements Runnable {
+ private int mKeyCode;
+ public KeyEventRunnable(int keyCode) {
+ mKeyCode = keyCode;
+ }
+ @Override
+ public void run() {
+ mDevice.pressKeyCode(mKeyCode);
+ }
}
}
diff --git a/libraries/base-app-helpers/src/android/platform/test/helpers/IStandardAppHelper.java b/libraries/base-app-helpers/src/android/platform/test/helpers/IStandardAppHelper.java
index 017cdce..9658429 100644
--- a/libraries/base-app-helpers/src/android/platform/test/helpers/IStandardAppHelper.java
+++ b/libraries/base-app-helpers/src/android/platform/test/helpers/IStandardAppHelper.java
@@ -61,9 +61,9 @@
* Setup expectations: None
* <p>
* This method will return the version String from PackageManager.
- * @param pkgName the application package
- * @throws NameNotFoundException if the package is not found in PM
+ *
* @return the version as a String
+ * @throws NameNotFoundException if the package is not found in PM
*/
abstract String getVersion() throws NameNotFoundException;
diff --git a/libraries/launcher-helper/src/android/support/test/launcherhelper/ILeanbackLauncherStrategy.java b/libraries/launcher-helper/src/android/support/test/launcherhelper/ILeanbackLauncherStrategy.java
index 52e3eea..54fccde 100644
--- a/libraries/launcher-helper/src/android/support/test/launcherhelper/ILeanbackLauncherStrategy.java
+++ b/libraries/launcher-helper/src/android/support/test/launcherhelper/ILeanbackLauncherStrategy.java
@@ -64,6 +64,18 @@
public BySelector getSettingsRowSelector();
/**
+ * Returns a {@link BySelector} describing the app widget (eg, clock widget)
+ * @return
+ */
+ public BySelector getAppWidgetSelector();
+
+ /**
+ * Returns a {@link BySelector} describing the Now Playing card
+ * @return
+ */
+ public BySelector getNowPlayingCardSelector();
+
+ /**
* Returns a {@link UiObject2} describing the focused search row
* @return
*/
@@ -92,4 +104,16 @@
* @return
*/
public UiObject2 selectSettingsRow();
+
+ /**
+ * Returns whether there is a match for the given app widget selector.
+ * @return
+ */
+ public boolean hasAppWidgetSelector();
+
+ /**
+ * Returns whether there is a Now Playing card on leanback launcher
+ * @return
+ */
+ public boolean hasNowPlayingCard();
}
diff --git a/libraries/launcher-helper/src/android/support/test/launcherhelper/LeanbackLauncherStrategy.java b/libraries/launcher-helper/src/android/support/test/launcherhelper/LeanbackLauncherStrategy.java
index cd3a36b..db3b0ac 100644
--- a/libraries/launcher-helper/src/android/support/test/launcherhelper/LeanbackLauncherStrategy.java
+++ b/libraries/launcher-helper/src/android/support/test/launcherhelper/LeanbackLauncherStrategy.java
@@ -34,6 +34,7 @@
private static final int MAX_SCROLL_ATTEMPTS = 20;
private static final int APP_LAUNCH_TIMEOUT = 10000;
private static final int SHORT_WAIT_TIME = 5000; // 5 sec
+ private static final int NOTIFICATION_WAIT_TIME = 30000;
protected UiDevice mDevice;
@@ -143,8 +144,24 @@
*/
@Override
public BySelector getSettingsRowSelector() {
- return By.res(getSupportedLauncherPackage(), "list").desc("")
- .hasDescendant(By.res("icon"));
+ return By.res(getSupportedLauncherPackage(), "list").desc("").hasDescendant(
+ By.res(getSupportedLauncherPackage(), "icon"), 3);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public BySelector getAppWidgetSelector() {
+ return By.clazz(getSupportedLauncherPackage(), "android.appwidget.AppWidgetHostView");
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public BySelector getNowPlayingCardSelector() {
+ return By.res(getSupportedLauncherPackage(), "content_text").text("Now Playing");
}
/**
@@ -225,7 +242,7 @@
mDevice.pressHome(); // Home key to move to the first card in the Notification row
}
return mDevice.wait(Until.findObject(
- getNotificationRowSelector().hasChild(By.focused(true))), SHORT_WAIT_TIME);
+ getNotificationRowSelector().hasDescendant(By.focused(true), 3)), SHORT_WAIT_TIME);
}
/**
@@ -247,12 +264,7 @@
@Override
public UiObject2 selectAppsRow() {
// Start finding Apps row from Notification row
- if (!isAppsRowSelected()) {
- selectNotificationRow();
- mDevice.pressDPadDown();
- }
- return mDevice.wait(Until.findObject(
- getAllAppsSelector().hasChild(By.focused(true))), SHORT_WAIT_TIME);
+ return findRow(getAppsRowSelector());
}
/**
@@ -260,17 +272,7 @@
*/
@Override
public UiObject2 selectGamesRow() {
- if (!isGamesRowSelected()) {
- selectAppsRow();
- mDevice.pressDPadDown();
- // If more than or equal to 16 apps are installed, the app banner could be cut off
- // into two rows at maximum. It needs to scroll down once more.
- if (!isGamesRowSelected()) {
- mDevice.pressDPadDown();
- }
- }
- return mDevice.wait(Until.findObject(
- getGamesRowSelector().hasChild(By.focused(true))), SHORT_WAIT_TIME);
+ return findRow(getGamesRowSelector());
}
/**
@@ -278,18 +280,30 @@
*/
@Override
public UiObject2 selectSettingsRow() {
- if (!isSettingsRowSelected()) {
- open();
- mDevice.pressHome(); // Home key to move to the first card in the Notification row
- // The Settings row is at the last position
- final int MAX_ROW_NUMS = 8;
- for (int i = 0; i < MAX_ROW_NUMS; ++i) {
- mDevice.pressDPadDown();
- }
+ // Assume that the Settings row is at the lowest bottom
+ UiObject2 settings = findRow(getSettingsRowSelector(), Direction.DOWN);
+ if (settings != null && isSettingsRowSelected()) {
+ return settings;
}
return null;
}
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public boolean hasAppWidgetSelector() {
+ return mDevice.wait(Until.hasObject(getAppWidgetSelector()), SHORT_WAIT_TIME);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public boolean hasNowPlayingCard() {
+ return mDevice.wait(Until.hasObject(getNowPlayingCardSelector()), SHORT_WAIT_TIME);
+ }
+
@SuppressWarnings("unused")
@Override
public BySelector getAllAppsButtonSelector() {
@@ -406,6 +420,10 @@
// The app icon is already found and focused.
long ready = SystemClock.uptimeMillis();
mDevice.pressDPadCenter();
+ if (!mDevice.wait(Until.hasObject(By.pkg(packageName).depth(0)), APP_LAUNCH_TIMEOUT)) {
+ Log.w(LOG_TAG, "no new window detected after app launch attempt.");
+ return ILauncherStrategy.LAUNCH_FAILED_TIMESTAMP;
+ }
mDevice.waitForIdle();
if (packageName != null) {
Log.w(LOG_TAG, String.format(
@@ -422,6 +440,41 @@
}
}
+ /**
+ * Launch the named notification
+ *
+ * @param appName - the name of the application to launch in the Notification row
+ * @return true if application is verified to be in foreground after launch; false otherwise.
+ */
+ public boolean launchNotification(String appName) {
+ // Wait until notification content is loaded
+ long currentTimeMs = System.currentTimeMillis();
+ while (isNotificationPreparing() &&
+ (System.currentTimeMillis() - currentTimeMs > NOTIFICATION_WAIT_TIME)) {
+ Log.d(LOG_TAG, "Preparing recommendation...");
+ SystemClock.sleep(SHORT_WAIT_TIME);
+ }
+
+ // Find a Notification that matches a given app name
+ UiObject2 card = findNotificationCard(
+ By.res(getSupportedLauncherPackage(), "card").descContains(appName));
+ if (card == null) {
+ throw new IllegalStateException(
+ String.format("The Notification that matches %s not found", appName));
+ }
+ Log.d(LOG_TAG,
+ String.format("The application %s found in the Notification row. [content_desc]%s",
+ appName, card.getContentDescription()));
+
+ // Click and wait until the Notification card opens
+ return mDevice.performActionAndWait(new Runnable() {
+ @Override
+ public void run() {
+ mDevice.pressDPadCenter();
+ }
+ }, Until.newWindow(), APP_LAUNCH_TIMEOUT);
+ }
+
protected boolean isSearchRowSelected() {
UiObject2 row = mDevice.findObject(getSearchRowSelector());
if (row == null) {
@@ -456,7 +509,9 @@
protected boolean isSettingsRowSelected() {
// Settings label is only visible if the settings row is selected
- return mDevice.hasObject(By.res(getSupportedLauncherPackage(), "label").text("Settings"));
+ UiObject2 row = mDevice.findObject(getSettingsRowSelector());
+ return (row != null && row.hasObject(
+ By.res(getSupportedLauncherPackage(), "label").text("Settings")));
}
protected boolean isAppOpen (String appPackage) {
@@ -474,6 +529,35 @@
}
}
+ protected boolean isNotificationPreparing() {
+ // Ensure that the Notification row is visible on screen
+ if (!mDevice.hasObject(getNotificationRowSelector())) {
+ selectNotificationRow();
+ }
+ return mDevice.hasObject(By.res(getSupportedLauncherPackage(), "notification_preparing"));
+ }
+
+ protected UiObject2 findNotificationCard(BySelector selector) {
+ // Move to the first notification, Search to the right
+ mDevice.pressHome();
+
+ // Find if a focused card matches a given selector
+ UiObject2 currentFocus = mDevice.findObject(getNotificationRowSelector())
+ .findObject(By.res(getSupportedLauncherPackage(), "card").focused(true));
+ UiObject2 previousFocus = null;
+ while (!currentFocus.equals(previousFocus)) {
+ if (currentFocus.hasObject(selector)) {
+ return currentFocus; // Found
+ }
+ mDevice.pressDPadRight();
+ previousFocus = currentFocus;
+ currentFocus = mDevice.findObject(getNotificationRowSelector())
+ .findObject(By.res(getSupportedLauncherPackage(), "card").focused(true));
+ }
+ Log.d(LOG_TAG, "Failed to find the Notification card until it reaches the end.");
+ return null;
+ }
+
protected UiObject2 findApp(UiObject2 container, UiObject2 focusedIcon, BySelector app) {
UiObject2 appIcon;
// The app icon is not on the screen.
@@ -504,4 +588,89 @@
} while (nextText != null && !nextText.equals(prevText));
return null;
}
+
+ /**
+ * Find the focused row that matches BySelector in a given direction.
+ * If the row is already selected, it returns regardless of the direction parameter.
+ * @param row
+ * @param direction
+ * @return
+ */
+ protected UiObject2 findRow(BySelector row, Direction direction) {
+ if (direction != Direction.DOWN && direction != Direction.UP) {
+ throw new IllegalArgumentException("Required to go either up or down to find rows");
+ }
+
+ UiObject2 currentFocused = mDevice.findObject(By.focused(true));
+ UiObject2 prevFocused = null;
+ while (!currentFocused.equals(prevFocused)) {
+ UiObject2 rowObject = mDevice.findObject(row);
+ if (rowObject != null && rowObject.hasObject(By.focused(true))) {
+ return rowObject; // Found
+ }
+
+ if (direction == Direction.DOWN) {
+ mDevice.pressDPadDown();
+ } else if (direction == Direction.UP) {
+ mDevice.pressDPadUp();
+ }
+ prevFocused = currentFocused;
+ currentFocused = mDevice.findObject(By.focused(true));
+ }
+ Log.d(LOG_TAG, "Failed to find the row until it reaches the end.");
+ return null;
+ }
+
+ protected UiObject2 findRow(BySelector row) {
+ UiObject2 rowObject;
+ // Search by going down first until it finds the focused row.
+ if ((rowObject = findRow(row, Direction.DOWN)) != null) {
+ return rowObject;
+ }
+ // If we haven't found it yet, search by going up
+ if ((rowObject = findRow(row, Direction.UP)) != null) {
+ return rowObject;
+ }
+ return null;
+ }
+
+ public void selectRestrictedProfile() {
+ UiObject2 button = findSettingInRow(
+ By.res(getSupportedLauncherPackage(), "label").text("Restricted Profile"),
+ Direction.RIGHT);
+ if (button == null) {
+ throw new IllegalStateException("Restricted Profile not found on launcher");
+ }
+ mDevice.pressDPadCenter();
+ mDevice.wait(Until.gone(getWorkspaceSelector()), APP_LAUNCH_TIMEOUT);
+ }
+
+ protected UiObject2 findSettingInRow(BySelector selector, Direction direction) {
+ if (direction != Direction.RIGHT && direction != Direction.LEFT) {
+ throw new IllegalArgumentException("Either left or right is allowed");
+ }
+ if (!isSettingsRowSelected()) {
+ selectSettingsRow();
+ }
+
+ UiObject2 setting;
+ UiObject2 currentFocused = mDevice.findObject(By.focused(true));
+ UiObject2 prevFocused = null;
+ while (!currentFocused.equals(prevFocused)) {
+ if ((setting = currentFocused.findObject(selector)) != null) {
+ return setting;
+ }
+
+ if (direction == Direction.RIGHT) {
+ mDevice.pressDPadRight();
+ } else if (direction == Direction.LEFT) {
+ mDevice.pressDPadLeft();
+ }
+ mDevice.waitForIdle();
+ prevFocused = currentFocused;
+ currentFocused = mDevice.findObject(By.focused(true));
+ }
+ Log.d(LOG_TAG, "Failed to find the setting in Settings row.");
+ return null;
+ }
}
diff --git a/tests/functional/tv/TvSysUiTests/Android.mk b/tests/functional/tv/TvSysUiTests/Android.mk
new file mode 100644
index 0000000..4670cec
--- /dev/null
+++ b/tests/functional/tv/TvSysUiTests/Android.mk
@@ -0,0 +1,28 @@
+# Copyright (C) 2016 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.
+
+LOCAL_PATH := $(call my-dir)
+include $(CLEAR_VARS)
+
+# ------------------------------------------------
+# build a test apk
+
+LOCAL_PACKAGE_NAME := TvSysUiTests
+LOCAL_CERTIFICATE := platform
+LOCAL_MODULE_TAGS := tests
+LOCAL_STATIC_JAVA_LIBRARIES := ub-uiautomator android-support-test platform-test-annotations \
+ leanback-app-helpers
+LOCAL_SRC_FILES := $(call all-java-files-under, src)
+
+include $(BUILD_PACKAGE)
diff --git a/tests/functional/tv/TvSysUiTests/AndroidManifest.xml b/tests/functional/tv/TvSysUiTests/AndroidManifest.xml
new file mode 100644
index 0000000..48a1cc0
--- /dev/null
+++ b/tests/functional/tv/TvSysUiTests/AndroidManifest.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2016 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="android.test.functional.tv.sysui">
+
+ <application>
+ <uses-library android:name="android.test.runner" />
+ </application>
+
+ <uses-sdk android:minSdkVersion="21"
+ android:targetSdkVersion="24"/>
+
+ <instrumentation
+ android:name="android.support.test.runner.AndroidJUnitRunner"
+ android:targetPackage="android.test.functional.tv.sysui"
+ android:label="TV Platform System UI Functional Tests" />
+</manifest>
diff --git a/tests/functional/tv/TvSysUiTests/src/android/test/functional/tv/common/SysUiTestBase.java b/tests/functional/tv/TvSysUiTests/src/android/test/functional/tv/common/SysUiTestBase.java
new file mode 100644
index 0000000..dec64fe
--- /dev/null
+++ b/tests/functional/tv/TvSysUiTests/src/android/test/functional/tv/common/SysUiTestBase.java
@@ -0,0 +1,105 @@
+/*
+ * Copyright (C) 2016 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 android.test.functional.tv.common;
+
+import android.app.Instrumentation;
+import android.content.Context;
+import android.os.Bundle;
+import android.platform.test.helpers.CommandHelper;
+import android.platform.test.helpers.DPadHelper;
+import android.support.test.InstrumentationRegistry;
+import android.support.test.launcherhelper.ILeanbackLauncherStrategy;
+import android.support.test.launcherhelper.LauncherStrategyFactory;
+import android.support.test.launcherhelper.LeanbackLauncherStrategy;
+import android.support.test.runner.AndroidJUnit4;
+import android.support.test.uiautomator.UiDevice;
+import android.util.Log;
+
+import org.junit.runner.RunWith;
+
+/**
+ * Base test class for functional testing for leanback platform
+ */
+@RunWith(AndroidJUnit4.class)
+public abstract class SysUiTestBase {
+
+ private static final String TAG = SysUiTestBase.class.getSimpleName();
+
+ protected UiDevice mDevice;
+ protected Instrumentation mInstrumentation;
+ protected Context mContext;
+ protected Bundle mArguments;
+
+ protected CommandHelper mCmdHelper;
+ protected DPadHelper mDPadHelper;
+ protected ILeanbackLauncherStrategy mLauncherStrategy;
+
+ public SysUiTestBase() {
+ initialize(InstrumentationRegistry.getInstrumentation());
+ }
+
+ public SysUiTestBase(Instrumentation instrumentation) {
+ initialize(instrumentation);
+ }
+
+ private void initialize(Instrumentation instrumentation) {
+ // Initialize instances of testing support library
+ mInstrumentation = instrumentation;
+ mContext = getInstrumentation().getContext();
+ mDevice = UiDevice.getInstance(getInstrumentation());
+ mArguments = InstrumentationRegistry.getArguments();
+
+ // Initialize instances of leanback and app helpers
+ ILeanbackLauncherStrategy launcherStrategy = LauncherStrategyFactory.getInstance(
+ mDevice).getLeanbackLauncherStrategy();
+ if (launcherStrategy instanceof LeanbackLauncherStrategy) {
+ mLauncherStrategy = (LeanbackLauncherStrategy) launcherStrategy;
+ }
+ mCmdHelper = new CommandHelper(getInstrumentation());
+ mDPadHelper = DPadHelper.getInstance(getInstrumentation());
+ }
+
+ protected Instrumentation getInstrumentation() {
+ return mInstrumentation;
+ }
+
+ protected int getArgumentsAsInt(String key, int defaultValue) {
+ String stringValue = mArguments.getString(key);
+ if (stringValue != null) {
+ try {
+ return Integer.parseInt(stringValue);
+ } catch (NumberFormatException e) {
+ Log.w(TAG, String.format("Unable to parse arg %s with value %s to a integer.",
+ key, stringValue), e);
+ }
+ }
+ return defaultValue;
+ }
+
+ protected boolean getArgumentsAsBoolean(String key, boolean defaultValue) {
+ String stringValue = mArguments.getString(key);
+ if (stringValue != null) {
+ try {
+ return Boolean.parseBoolean(stringValue);
+ } catch (Exception e) {
+ Log.w(TAG, String.format("Unable to parse arg %s with value to a boolean.",
+ key, stringValue), e);
+ }
+ }
+ return defaultValue;
+ }
+}
diff --git a/tests/functional/tv/TvSysUiTests/src/android/test/functional/tv/common/UiWatchers.java b/tests/functional/tv/TvSysUiTests/src/android/test/functional/tv/common/UiWatchers.java
new file mode 100644
index 0000000..90cb05c
--- /dev/null
+++ b/tests/functional/tv/TvSysUiTests/src/android/test/functional/tv/common/UiWatchers.java
@@ -0,0 +1,112 @@
+/*
+ * Copyright (C) 2016 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 android.test.functional.tv.common;
+
+import android.app.Instrumentation;
+import android.support.test.uiautomator.BySelector;
+import android.support.test.uiautomator.UiDevice;
+import android.support.test.uiautomator.UiObject2;
+import android.support.test.uiautomator.UiWatcher;
+import android.support.test.uiautomator.Until;
+import android.util.Log;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Utility class to monitor a UI object to dismiss.
+ */
+public final class UiWatchers {
+ private static final String LOG_TAG = UiWatchers.class.getSimpleName();
+ private static final long WAIT_TIME_MS = 3000;
+
+ private UiDevice mDevice;
+ private List<String> mWatcherNames = new ArrayList<>();
+
+
+ public UiWatchers(Instrumentation instrumentation) {
+ mDevice = UiDevice.getInstance(instrumentation);
+ }
+
+ /**
+ * Register a new watcher that looks for a object and dismisses it.
+ */
+ public void registerDismissWatcher(final String watcherName, final BySelector watch,
+ final BySelector click) {
+ if (mWatcherNames.contains(watcherName)) {
+ Log.i(LOG_TAG,
+ String.format("The watcher %s already registered. Skipped!", watcherName));
+ return;
+ }
+ mWatcherNames.add(watcherName);
+ mDevice.registerWatcher(watcherName, new UiWatcher() {
+ @Override
+ public boolean checkForCondition() {
+ if (mDevice.hasObject(watch)) {
+ UiObject2 dismiss = mDevice.wait(Until.findObject(click), WAIT_TIME_MS);
+ dismiss.click();
+ postHandler(watcherName);
+ return true; // triggered
+ }
+ return false; // not triggered
+ }
+ });
+ }
+
+ public void unregisterDismissWatcher(String watcherName) {
+ mDevice.removeWatcher(watcherName);
+ mWatcherNames.remove(watcherName);
+ }
+
+ /**
+ * Checks if any registered UiWatcher have triggered.
+ * @return
+ */
+ public boolean hasWatcherTriggered() {
+ for (String watcherName : mWatcherNames) {
+ if (hasWatcherTriggered(watcherName)) {
+ Log.i(LOG_TAG,
+ String.format("Found the watcher %s have triggered.", watcherName));
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Checks if a specific registered UiWatcher has triggered.
+ * @param watcherName
+ * @return
+ */
+ public boolean hasWatcherTriggered(String watcherName) {
+ if (!mWatcherNames.contains(watcherName)) {
+ Log.w(LOG_TAG, String.format("The watcher %s not registered.", watcherName));
+ }
+ return mDevice.hasWatcherTriggered(watcherName);
+ }
+
+ public void resetWatchers() {
+ mDevice.resetWatcherTriggers();
+ }
+
+ /**
+ * Current implementation ignores the exception and continues.
+ */
+ public void postHandler(String watcherName) {
+ Log.i(LOG_TAG, String.format("%s dismissed", watcherName));
+ }
+}
diff --git a/tests/functional/tv/TvSysUiTests/src/android/test/functional/tv/settings/TestAll.java b/tests/functional/tv/TvSysUiTests/src/android/test/functional/tv/settings/TestAll.java
new file mode 100644
index 0000000..5246b61
--- /dev/null
+++ b/tests/functional/tv/TvSysUiTests/src/android/test/functional/tv/settings/TestAll.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2016 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 android.test.functional.tv.settings;
+
+import org.junit.runner.RunWith;
+import org.junit.runners.Suite;
+
+/**
+ * adb shell am instrument -w -r \
+ * -e class android.test.functional.tv.settings.TestAll \
+ * android.test.functional.tv.sysui/android.support.test.runner.AndroidJUnitRunner
+ */
+@RunWith(Suite.class)
+@Suite.SuiteClasses({
+})
+public class TestAll {
+ // the class remains empty,
+ // used only as a holder for the above annotations
+}