chromium/chrome/test/android/javatests/src/org/chromium/chrome/test/pagecontroller/utils/UiLocatorHelper.java

// Copyright 2019 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.chrome.test.pagecontroller.utils;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.uiautomator.StaleObjectException;
import androidx.test.uiautomator.UiDevice;
import androidx.test.uiautomator.UiObject2;

import org.chromium.base.test.util.TimeoutTimer;

import java.util.ArrayList;
import java.util.List;

/**
 * Convenience helper for IUi2Locators, with retry capability to reduce flakes.
 *
 * IUi2Locators contain matching criteria to find UI nodes on a device, but it
 * lacks the ability to retry on failures.  They also do not give directly useful
 * information such as the text displayed in an UI node that tests will likely need.
 * This helper class provides these capabilities.
 */
public class UiLocatorHelper {
    private static final long DEFAULT_TIMEOUT_MS = 3000L;
    // UI_CHECK_INTERVAL_MS is intentionally not modifiable so that longer timeouts
    // don't lead to slowness due to the checking interval being too coarse.
    static final long UI_CHECK_INTERVAL_MS = DEFAULT_TIMEOUT_MS / 4L;

    private static final ElementConverter<String> CONVERTER_TEXT =
            object2 -> {
                return object2.getText();
            };

    private static final ElementConverter<String> CONVERTER_DESC =
            object2 -> {
                return object2.getContentDescription();
            };

    private static final ElementConverter<Boolean> CONVERTER_CHECKED =
            object2 -> {
                if (object2.isCheckable()) {
                    return object2.isChecked();
                } else {
                    throw new UiLocationException("Item in " + object2 + " is not checkable.");
                }
            };

    private final UiDevice mDevice;
    private long mTimeout;

    /** Create a UiLocatorHelper with default timeout. */
    UiLocatorHelper() {
        this(DEFAULT_TIMEOUT_MS);
    }

    /**
     * Create a UiLocatorHelper with specified timeout.
     * @param timeout Timeout in milliseconds.
     */
    UiLocatorHelper(long timeout) {
        mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
        mTimeout = timeout;
    }

    /**
     * Determines if a node is found on the screen.
     * @param locator Locator used to find the node.
     * @return        true if node is found, false otherwise.
     */
    public boolean isOnScreen(@NonNull IUi2Locator locator) {
        try {
            getOne(locator);
        } catch (UiLocationException e) {
            return false;
        }
        return true;
    }

    /**
     * Determines if a node is found on the screen, does not retry.
     * @param locator Locator used to find the node.
     * @param root    Node to search under, or null if all the nodes should be searched.
     *                Possible root staleness will make retries ineffective, this means
     *                that it is only suitable for immediate methods to take a root node.
     * @return        true if node is found, false otherwise.
     */
    public boolean isOnScreenImmediate(@NonNull IUi2Locator locator, UiObject2 root) {
        return getOneImmediate(locator, root) == null;
    }

    /**
     * Checks that a node is found on the screen.
     * @param locator Locator used to find the node.
     * @throws        UiLocationException if locator is not found on screen.
     */
    public void verifyOnScreen(@NonNull IUi2Locator locator) {
        getOne(locator);
    }

    /**
     * Get text from a single node using locator.
     * @param locator          Locator used to find the nodes.
     * @return                 Text field of the node.
     * @throws UiLocationException if locator didn't find any nodes within timeout interval.
     */
    public String getOneText(@NonNull IUi2Locator locator) {
        return getOneElement(locator, CONVERTER_TEXT);
    }

    /**
     * Get list of text using locator.
     * @param locator          Locator used to find the nodes.
     * @return                 List of text.
     * @throws UiLocationException if locator didn't find any nodes within timeout interval.
     */
    public List<String> getAllTexts(@NonNull IUi2Locator locator) {
        return getAllElements(locator, CONVERTER_TEXT);
    }

    /**
     * Get the node's text without retries.
     * @param locator Locator used to find the nodes.
     * @param root    Node to search under, or null if all the nodes should be searched.
     *                Possible root staleness will make retries ineffective, this means
     *                that it is only suitable for immediate methods to take a root node.
     * @return        Text field of the node, or null if locator didn't find any nodes.
     */
    public String getOneTextImmediate(@NonNull IUi2Locator locator, @Nullable UiObject2 root) {
        return getOneElementImmediate(locator, root, CONVERTER_TEXT);
    }

    /**
     * Get list of text using locator without retries.
     * @param locator Locator used to find the nodes.
     * @param root    Node to search under, or null if all the nodes should be searched.
     *                Possible root staleness will make retries ineffective, this means
     *                that it is only suitable for immediate methods to take a root node.
     * @return        List of text, or empty list if locator didn't find any nodes.
     */
    public List<String> getAllTextsImmediate(
            @NonNull IUi2Locator locator, @Nullable UiObject2 root) {
        return getAllElementsImmediate(locator, root, CONVERTER_TEXT);
    }

    /**
     * Get content description from a single node using locator.
     * @param locator          Locator used to find the nodes.
     * @return                 Content description field of the node.
     * @throws UiLocationException if locator didn't find any nodes within timeout interval.
     */
    public String getOneDescription(@NonNull IUi2Locator locator) {
        return getOneElement(locator, CONVERTER_DESC);
    }

    /**
     * Get list of content descriptions using locator.
     * @param locator          Locator used to find the nodes.
     * @return                 List of content descriptions.
     * @throws UiLocationException if locator didn't find any nodes within timeout interval.
     */
    public List<String> getAllDescriptions(@NonNull IUi2Locator locator) {
        return getAllElements(locator, CONVERTER_DESC);
    }

    /**
     * Get the node's content description without retries.
     * @param locator Locator used to find the nodes.
     * @param root    Node to search under, or null if all the nodes should be searched.
     *                Possible root staleness will make retries ineffective, this means
     *                that it is only suitable for immediate methods to take a root node.
     * @return        Content description of the node, or null if locator didn't find any nodes.
     */
    public String getOneDescriptionImmediate(
            @NonNull IUi2Locator locator, @Nullable UiObject2 root) {
        return getOneElementImmediate(locator, root, CONVERTER_DESC);
    }

    /**
     * Get list of content descriptions using locator without retries.
     * @param locator Locator used to find the nodes.
     * @param root    Node to search under, or null if all the nodes should be searched.
     *                Possible root staleness will make retries ineffective, this means
     *                that it is only suitable for immediate methods to take a root node.
     * @return        List of content descriptions, or empty list if locator didn't find any nodes.
     */
    public List<String> getAllDescriptionsImmediate(
            @NonNull IUi2Locator locator, @Nullable UiObject2 root) {
        return getAllElementsImmediate(locator, root, CONVERTER_DESC);
    }

    /**
     * Get checked status from a single node using locator.
     * @param locator           Locator used to find the nodes.
     * @return                  checked status of the node.
     * @throws  UiLocationException if locator didn't find any nodes within timeout interval.
     */
    public Boolean getOneChecked(@NonNull IUi2Locator locator) {
        return getOneElement(locator, CONVERTER_CHECKED);
    }

    /**
     * Get list of checked statuses using locator.
     * @param locator          Locator used to find the nodes.
     * @return                 List of checked statuses.
     * @throws UiLocationException if locator didn't find any nodes within timeout interval.
     */
    public List<Boolean> getAllChecked(@NonNull IUi2Locator locator) {
        return getAllElements(locator, CONVERTER_CHECKED);
    }

    /**
     * Get the node's checked status without retries.
     * @param locator Locator used to find the nodes.
     * @param root    Node to search under, or null if all the nodes should be searched.
     *                Possible root staleness will make retries ineffective, this means
     *                that it is only suitable for immediate methods to take a root node.
     * @return        Checked status of the node, or null if locator didn't find any nodes.
     */
    public Boolean getOneCheckedImmediate(@NonNull IUi2Locator locator, @Nullable UiObject2 root) {
        return getOneElementImmediate(locator, root, CONVERTER_CHECKED);
    }

    /**
     * Get list of checked statuses using locator without retries.
     * @param locator Locator used to find the nodes.
     * @param root    Node to search under, or null if all the nodes should be searched.
     *                Possible root staleness will make retries ineffective, this means
     *                that it is only suitable for immediate methods to take a root node.
     * @return        List of checked statuses, or empty list if locator didn't find any nodes.
     */
    public List<Boolean> getAllCheckedImmediate(
            @NonNull IUi2Locator locator, @Nullable UiObject2 root) {
        return getAllElementsImmediate(locator, root, CONVERTER_CHECKED);
    }

    /**
     * Returns the first element found using locator.
     * Throws UiLocationException if not found.
     * @param locator Locator to use to find the element.
     * @return        UiObject2
     */
    public UiObject2 getOne(@NonNull IUi2Locator locator) {
        List<UiObject2> all = getAll(locator);
        return all.get(0);
    }

    /**
     * Returns the first element found using locator.
     * Could return null but does not throw.
     * @param locator Locator to use to find the element.
     * @param root    Search for elements within root, or on the device if null.
     *                Possible root staleness will make retries ineffective, this means
     *                that it is only suitable for immediate methods to take a root node.
     * @return        UiObject2
     */
    public UiObject2 getOneImmediate(@NonNull IUi2Locator locator, @Nullable UiObject2 root) {
        List<UiObject2> all = getAllImmediate(locator, root);
        if (all.size() == 0) {
            return null;
        } else {
            return all.get(0);
        }
    }

    /**
     * Return a list of UiObject2 nodes matching locator, will retry up to timeout.
     * @param locator          Locator to use to find the element.
     * @return                 list of elements matching the locator
     * @throws UiLocationException if locator didn't find any nodes.
     */
    public List<UiObject2> getAll(IUi2Locator locator) {
        TimeoutTimer timeoutTimer = new TimeoutTimer(mTimeout);
        int attempts = 0;
        List<UiObject2> object2s = null;
        while (true) {
            try {
                object2s = getAllInternal(locator, null);
            } catch (StaleObjectException | NullPointerException e) {
            } finally {
                attempts++;
            }
            if (object2s != null && object2s.size() != 0) {
                return object2s;
            }
            if (timeoutTimer.isTimedOut()) {
                break;
            }
            Utils.sleep(UI_CHECK_INTERVAL_MS);
        }
        throw new UiLocationException(
                "Could not find any objects after "
                        + mTimeout
                        + "ms and "
                        + attempts
                        + " attempts.",
                locator);
    }

    /**
     * Return a list of UiObject2 nodes matching locator, without retries.
     * @param locator Locator to use to find the element.
     * @param root    the root element to search under, or null to search for any
     *                node on the device.
     *                Possible root staleness will make retries ineffective, this means
     *                that it is only suitable for immediate methods to take a root node.
     * @return        list of elements matching the locator, or empty if nothing
     *                matched.
     */
    public List<UiObject2> getAllImmediate(@NonNull IUi2Locator locator, UiObject2 root) {
        try {
            return getAllInternal(locator, root);
        } catch (StaleObjectException | NullPointerException e) {
            return new ArrayList<UiObject2>();
        }
    }

    /**
     * Delegate to be used with getCustomElements.
     * @param <T> The type of the element.
     */
    public static interface CustomElementMaker<T> {
        /**
         * Should construct an element given a node.
         * @param root          The input node.
         * @param isLastAttempt getCustomElements may call this delegate
         *                      multiple times if errors are thrown from this
         *                      method and timeout has not been reached.  If
         *                      isLastAttempt is true, then it indicates that
         *                      getCustomElements will not call this delegate
         *                      again.  For example the delegate can return null
         *                      on the lastAttempt if it still encounters errors
         *                      which indicates that a properly formed element is
         *                      not found on the page, this case could happen if
         *                      an element gets cutoff at a scroll boundary.
         * @return              The element if construction is successful, null
         *                      otherwise.
         * @throws UiLocationException Should throw a UiLocationException or
         *                      UiStaleObjectException if getCustomElements
         *                      should re-obtain a root using its provided
         *                      locator.
         */
        T makeElement(UiObject2 root, boolean isLastAttempt);
    }

    /**
     * Create a list of objects based on nodes found by locator.
     * @param locator Locator used to find the nodes.
     * @param maker   CustomElementMaker used to construct the custom element.
     * @param <T>     The type of the objects in the list.
     * @return        List of constructed objects of type T.
     */
    public <T> List<T> getCustomElements(
            @NonNull IUi2Locator locator, @NonNull CustomElementMaker<T> maker) {
        List<T> elements = new ArrayList<>();
        List<UiObject2> roots;
        boolean isLastAttempt = false;

        TimeoutTimer lastAttemptTimer = new TimeoutTimer(mTimeout - UI_CHECK_INTERVAL_MS);
        while (true) {
            try {
                try {
                    roots = getAllInternal(locator, null);
                } catch (UiLocationException e) {
                    return elements;
                }
                for (UiObject2 root : roots) {
                    T e = maker.makeElement(root, isLastAttempt);
                    if (e != null) {
                        elements.add(e);
                    }
                }
                return elements;
            } catch (StaleObjectException | UiLocationException e) {
                if (isLastAttempt) {
                    throw e;
                }
                // makeElement could throw while going through the list of roots,
                // so clear out any elements to avoid duplicates and stale ones.
                elements.clear();
                Utils.sleep(UI_CHECK_INTERVAL_MS);
                // If the next interaction will cause timeout to be exceeded, then
                // flag isLastAttempt so client can choose to perform a work-around
                // instead of throwing an exception again.
                if (lastAttemptTimer.isTimedOut()) {
                    isLastAttempt = true;
                }
            }
        }
    }

    /**
     * Define a conversion method creates an object from info in a UiObject2 node.
     * @param <T> Type of the object.
     */
    private static interface ElementConverter<T> {
        T convert(UiObject2 object2);
    }

    private <T> T getOneElement(IUi2Locator locator, ElementConverter<T> converter) {
        List<T> all = getAllElements(locator, converter);
        if (all.size() > 0) {
            return all.get(0);
        } else {
            return null;
        }
    }

    private <T> T getOneElementImmediate(
            IUi2Locator locator, UiObject2 root, ElementConverter<T> converter) {
        List<T> all = getAllElementsImmediate(locator, root, converter);
        return Utils.nullableGet(all, 0);
    }

    private <T> List<T> getAllElementsImmediate(
            IUi2Locator locator, UiObject2 root, ElementConverter<T> elementConverter) {
        List<UiObject2> all = getAllImmediate(locator, root);
        return convertAll(elementConverter, all);
    }

    private <T> List<T> getAllElements(IUi2Locator locator, ElementConverter<T> elementConverter) {
        List<UiObject2> all = getAll(locator);
        return convertAll(elementConverter, all);
    }

    // Convert each item in all and return the resultant list.
    private <T> List<T> convertAll(ElementConverter<T> converter, List<UiObject2> all) {
        List<T> allT = new ArrayList<>();
        for (UiObject2 object2 : all) {
            allT.add(converter.convert(object2));
        }
        return allT;
    }

    /**
     * Returns all nodes that matched the locator.
     * Note that StaleObject or NPE may be thrown from this if the UI has gotten into an
     * inconsistent state, this usually means the caller should retry the operation.
     * @param locator Locator to use.
     * @param root    Root node to match under, or null to match on any node.
     * @return        List of matched nodes, possibly empty.
     */
    private List<UiObject2> getAllInternal(@NonNull IUi2Locator locator, UiObject2 root) {
        return root == null ? locator.locateAll(mDevice) : locator.locateAll(root);
    }
}