chromium/chrome/test/android/javatests/src/org/chromium/chrome/test/util/ChromeTabUtils.java

// Copyright 2015 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.util;

import android.app.Instrumentation;
import android.text.TextUtils;
import android.view.View;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.test.platform.app.InstrumentationRegistry;

import org.junit.Assert;

import org.chromium.base.Log;
import org.chromium.base.ThreadUtils;
import org.chromium.base.supplier.ObservableSupplier;
import org.chromium.base.test.util.CallbackHelper;
import org.chromium.base.test.util.CriteriaHelper;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.ChromeTabbedActivity;
import org.chromium.chrome.browser.app.ChromeActivity;
import org.chromium.chrome.browser.compositor.layouts.components.CompositorButton;
import org.chromium.chrome.browser.compositor.overlays.strip.StripLayoutHelper;
import org.chromium.chrome.browser.tab.EmptyTabObserver;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabCreationState;
import org.chromium.chrome.browser.tab.TabHidingType;
import org.chromium.chrome.browser.tab.TabLaunchType;
import org.chromium.chrome.browser.tab.TabSelectionType;
import org.chromium.chrome.browser.tab.TabWebContentsObserver;
import org.chromium.chrome.browser.tabmodel.TabModel;
import org.chromium.chrome.browser.tabmodel.TabModelFilter;
import org.chromium.chrome.browser.tabmodel.TabModelObserver;
import org.chromium.chrome.browser.tabmodel.TabModelSelector;
import org.chromium.chrome.browser.tabmodel.TabModelSelectorSupplier;
import org.chromium.chrome.browser.tabmodel.TabModelUtils;
import org.chromium.chrome.browser.tasks.tab_groups.TabGroupModelFilter;
import org.chromium.chrome.test.ChromeTabbedActivityTestRule;
import org.chromium.chrome.test.util.browser.TabTitleObserver;
import org.chromium.content_public.browser.LoadUrlParams;
import org.chromium.content_public.browser.RenderWidgetHostView;
import org.chromium.content_public.browser.WebContents;
import org.chromium.content_public.browser.test.util.TestTouchUtils;
import org.chromium.ui.base.WindowAndroid;
import org.chromium.url.GURL;

import java.util.List;
import java.util.Locale;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicReference;

/** A utility class that contains methods generic to all Tabs tests. */
public class ChromeTabUtils {
    private static final String TAG = "ChromeTabUtils";
    public static final int TITLE_UPDATE_TIMEOUT_SECONDS = 3;

    /**
     * The required page load percentage for the page to be considered ready assuming the
     * TextureView is also ready.
     */
    private static final float CONSIDERED_READY_LOAD_PERCENTAGE = 1;

    /**
     * An observer that waits for a Tab to load a page.
     *
     * The observer can be configured to either wait for the Tab to load a specific page
     * (if expectedUrl is non-null) or any page (otherwise). On seeing the tab finish
     * a page load or crash, the observer will notify the provided callback and stop
     * watching the tab. On load stop, the observer will decrement the provided latch
     * and continue watching the page in case the tab subsequently crashes or finishes
     * a page load.
     *
     * This may seem complicated, but it's intended to handle three distinct cases:
     *  1) Successful page load + observer starts watching before onPageLoadFinished fires.
     *     This is the most normal case: onPageLoadFinished fires, then onLoadStopped fires,
     *     and we see both.
     *  2) Crash on page load. onLoadStopped fires, then onCrash fires, and we see both.
     *  3) Successful page load + observer starts watching after onPageLoadFinished fires.
     *     We miss the onPageLoadFinished and *only* see onLoadStopped.
     *
     * Receiving onPageLoadFinished is sufficient to know that we're dealing with scenario #1.
     * Receiving onCrash is sufficient to know that we're dealing with scenario #2.
     * Receiving onLoadStopped without a preceding onPageLoadFinished indicates that we're dealing
     * with either scenario #2 *or* #3, so we have to keep watching for a call to onCrash.
     */
    private static class TabPageLoadedObserver extends EmptyTabObserver {
        private CallbackHelper mCallback;
        private String mExpectedUrl;
        private CountDownLatch mLoadStoppedLatch;

        public TabPageLoadedObserver(
                CallbackHelper loadCompleteCallback,
                String expectedUrl,
                CountDownLatch loadStoppedLatch) {
            mCallback = loadCompleteCallback;
            mExpectedUrl = expectedUrl;
            mLoadStoppedLatch = loadStoppedLatch;
        }

        @Override
        public void onCrash(Tab tab) {
            mCallback.notifyFailed("Tab crashed :(");
            tab.removeObserver(this);
        }

        @Override
        public void onLoadStopped(Tab tab, boolean toDifferentDocument) {
            mLoadStoppedLatch.countDown();
        }

        @Override
        public void onPageLoadFinished(Tab tab, GURL url) {
            if (mExpectedUrl == null || TextUtils.equals(url.getSpec(), mExpectedUrl)) {
                mCallback.notifyCalled();
                tab.removeObserver(this);
            }
        }
    }

    private static boolean loadComplete(Tab tab, String url) {
        return !tab.isLoading()
                && (url == null || TextUtils.equals(getUrlStringOnUiThread(tab), url))
                && !tab.getWebContents().shouldShowLoadingUI();
    }

    public static String getTitleOnUiThread(Tab tab) {
        AtomicReference<String> res = new AtomicReference<>();
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    res.set(tab.getTitle());
                });
        return res.get();
    }

    public static String getUrlStringOnUiThread(Tab tab) {
        AtomicReference<String> res = new AtomicReference<>();
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    res.set(tab.getUrl().getSpec());
                });
        return res.get();
    }

    public static GURL getUrlOnUiThread(Tab tab) {
        AtomicReference<GURL> res = new AtomicReference<>();
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    res.set(tab.getUrl());
                });
        return res.get();
    }

    /**
     * Waits for the given tab to finish loading the given URL, or, if the given URL is
     * null, waits for the current page to load.
     *
     * @param tab The tab to wait for the page loading to be complete.
     * @param url The URL that will be waited to load for.  Pass in null if loading the
     *            current page is sufficient.
     */
    public static void waitForTabPageLoaded(final Tab tab, @Nullable final String url) {
        waitForTabPageLoaded(tab, url, null, 10L);
    }

    /**
     * Waits for the given tab to load the given URL, or, if the given URL is null, waits
     * for the triggered load to complete.
     *
     * @param tab The tab to wait for the page loading to be complete.
     * @param url The expected url of the loaded page.  Pass in null if loading the
     *            current page is sufficient.
     * @param loadTrigger The trigger action that will result in a page load finished event
     *                    to be fired (not run on the UI thread by default).
     */
    public static void waitForTabPageLoaded(
            final Tab tab, @Nullable final String url, @Nullable Runnable loadTrigger) {
        waitForTabPageLoaded(tab, url, loadTrigger, CallbackHelper.WAIT_TIMEOUT_SECONDS);
    }

    /**
     * Waits for the given tab to finish loading its current page.
     *
     * @param tab The tab to wait for the page loading to be complete.
     * @param loadTrigger The trigger action that will result in a page load finished event
     *                    to be fired (not run on the UI thread by default).
     * @param secondsToWait The number of seconds to wait for the page to be loaded.
     */
    public static void waitForTabPageLoaded(
            final Tab tab, Runnable loadTrigger, long secondsToWait) {
        waitForTabPageLoaded(tab, null, loadTrigger, secondsToWait);
    }

    /**
     * Waits for the given tab to load the given URL, or, if the given URL is null, waits
     * for the triggered load to complete.
     *
     * @param tab The tab to wait for the page loading to be complete.
     * @param url The expected url of the loaded page.  Pass in null if loading the
     *            current page is sufficient.
     * @param loadTrigger The trigger action that will result in a page load finished event
     *                    to be fired (not run on the UI thread by default).  Pass in null if the
     *                    load is triggered externally.
     * @param secondsToWait The number of seconds to wait for the page to be loaded.
     */
    public static void waitForTabPageLoaded(
            final Tab tab,
            @Nullable final String url,
            @Nullable Runnable loadTrigger,
            long secondsToWait) {
        Assert.assertFalse(ThreadUtils.runningOnUiThread());

        final CountDownLatch loadStoppedLatch = new CountDownLatch(1);
        final CallbackHelper loadedCallback = new CallbackHelper();
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    // Don't check for the load being already complete if there is a trigger to run.
                    if (loadTrigger == null && loadComplete(tab, url)) {
                        loadedCallback.notifyCalled();
                        return;
                    }
                    tab.addObserver(
                            new TabPageLoadedObserver(loadedCallback, url, loadStoppedLatch));
                });
        if (loadTrigger != null) {
            loadTrigger.run();
        }
        try {
            loadedCallback.waitForCallback(0, 1, secondsToWait, TimeUnit.SECONDS);
        } catch (TimeoutException e) {
            // In the event that:
            //  1) the tab is on the correct page
            //  2) we weren't notified that the page load finished
            //  3) we *were* notified that the tab stopped loading
            //  4) the tab didn't crash
            //
            // then it's likely the case that we started observing the tab after
            // onPageLoadFinished but before onLoadStopped. (The latter sets tab.mIsLoading to
            // false.) Try to carry on with the test.
            if (loadStoppedLatch.getCount() == 0
                    && ThreadUtils.runOnUiThreadBlocking(() -> loadComplete(tab, url))) {
                Log.w(
                        TAG,
                        "onPageLoadFinished was never called, but loading stopped "
                                + "on the expected page. Tentatively continuing.");
            } else {
                Assert.fail("Page did not load. " + tabDebugInfo(tab, url));
            }
        }

        boolean complete = ThreadUtils.runOnUiThreadBlocking(() -> loadComplete(tab, url));

        if (complete) return;

        CriteriaHelper.pollUiThread(
                () -> {
                    return loadComplete(tab, url);
                },
                "Tab failed to complete load after additional polling. " + tabDebugInfo(tab, url));
    }

    private static String tabDebugInfo(final Tab tab, @Nullable final String url) {
        WebContents webContents = tab.getWebContents();
        boolean shouldShowLoadingUI = false;
        if (webContents != null) {
            shouldShowLoadingUI =
                    ThreadUtils.runOnUiThreadBlocking(() -> webContents.shouldShowLoadingUI());
        }
        return String.format(
                Locale.ENGLISH,
                "Tab information at time of failure -- "
                        + "expected url: '%s', actual URL: '%s', load progress: %d, is "
                        + "loading: %b, web contents init: %b, web contents loading: %b",
                url,
                getUrlStringOnUiThread(tab),
                Math.round(100 * tab.getProgress()),
                tab.isLoading(),
                webContents != null,
                shouldShowLoadingUI);
    }

    /**
     * Waits for the given tab to start loading its current page.
     *
     * @param tab The tab to wait for the page loading to be started.
     * @param expectedUrl The expected url of the started page load.  Pass in null if starting
     *                    any load is sufficient.
     * @param loadTrigger The trigger action that will result in a page load started event
     *                    to be fired (not run on the UI thread by default).
     */
    public static void waitForTabPageLoadStart(
            final Tab tab, @Nullable final String expectedUrl, Runnable loadTrigger) {
        waitForTabPageLoadStart(tab, expectedUrl, loadTrigger, CallbackHelper.WAIT_TIMEOUT_SECONDS);
    }

    /**
     * Waits for the given tab to start loading its current page.
     *
     * @param tab The tab to wait for the page loading to be started.
     * @param expectedUrl The expected url of the started page load. Pass in null if starting any
     *     load is sufficient.
     * @param loadTrigger The trigger action that will result in a page load started event to be
     *     fired (not run on the UI thread by default).
     * @param secondsToWait The number of seconds to wait for the page to be load to be started.
     */
    public static void waitForTabPageLoadStart(
            final Tab tab,
            @Nullable final String expectedUrl,
            Runnable loadTrigger,
            long secondsToWait) {
        final CallbackHelper startedCallback = new CallbackHelper();
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    tab.addObserver(
                            new EmptyTabObserver() {
                                @Override
                                public void onPageLoadStarted(Tab tab, GURL url) {
                                    if (expectedUrl == null
                                            || TextUtils.equals(url.getSpec(), expectedUrl)) {
                                        startedCallback.notifyCalled();
                                        tab.removeObserver(this);
                                    }
                                }
                            });
                });
        loadTrigger.run();
        try {
            startedCallback.waitForCallback(0, 1, secondsToWait, TimeUnit.SECONDS);
        } catch (TimeoutException e) {
            Assert.fail(
                    "Page did not start loading.  Tab information at time of failure --"
                            + " url: "
                            + tab.getUrl().getSpec()
                            + ", load progress: "
                            + tab.getProgress()
                            + ", is loading: "
                            + Boolean.toString(tab.isLoading()));
        }
    }

    /**
     * An observer that waits for a Tab to become interactable.
     *
     * Notifies the provided callback when:
     *  - the page has become interactable
     *  - the tab has been hidden and will not become interactable.
     * Stops observing with a failure if the tab has crashed.
     *
     * We treat the hidden case as success to handle loads in which a page immediately closes itself
     * or opens a new foreground tab (popup), and may not become interactable.
     */
    private static class TabPageInteractableObserver extends EmptyTabObserver {
        private Tab mTab;
        private CallbackHelper mCallback;

        public TabPageInteractableObserver(Tab tab, CallbackHelper interactableCallback) {
            mTab = tab;
            mCallback = interactableCallback;
        }

        @Override
        public void onCrash(Tab tab) {
            mCallback.notifyFailed("Tab crashed :(");
            mTab.removeObserver(this);
        }

        @Override
        public void onHidden(Tab tab, @TabHidingType int type) {
            mCallback.notifyCalled();
            mTab.removeObserver(this);
        }

        @Override
        public void onInteractabilityChanged(Tab tab, boolean interactable) {
            if (interactable) {
                mCallback.notifyCalled();
                mTab.removeObserver(this);
            }
        }
    }

    /**
     * Waits for the tab to become interactable. This occurs after load, once all view
     * animations have completed.
     *
     * @param tab The tab to wait for interactability on.
     */
    public static void waitForInteractable(final Tab tab) {
        Assert.assertFalse(ThreadUtils.runningOnUiThread());

        final CallbackHelper interactableCallback = new CallbackHelper();
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    // If a tab is hidden, don't wait for interactivity. See note in
                    // TabPageInteractableObserver.
                    if (tab.isUserInteractable() || tab.isHidden()) {
                        interactableCallback.notifyCalled();
                        return;
                    }
                    tab.addObserver(new TabPageInteractableObserver(tab, interactableCallback));
                });

        try {
            interactableCallback.waitForCallback(0, 1, 10L, TimeUnit.SECONDS);
        } catch (TimeoutException e) {
            Assert.fail("Page never became interactable.");
        }
    }

    /**
     * Switch to the given TabIndex in the current tabModel.
     *
     * @param tabIndex
     */
    public static void switchTabInCurrentTabModel(
            final ChromeActivity activity, final int tabIndex) {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    TabModelUtils.setIndex(activity.getCurrentTabModel(), tabIndex);
                });
    }

    /**
     * Simulates a click to the normal (not incognito) new tab button.
     * <p>
     * Does not wait for the tab to be loaded.
     */
    public static void clickNewTabButton(
            Instrumentation instrumentation, ChromeTabbedActivity activity) {
        final TabModel normalTabModel = activity.getTabModelSelector().getModel(false);
        final CallbackHelper createdCallback = new CallbackHelper();
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    normalTabModel.addObserver(
                            new TabModelObserver() {
                                @Override
                                public void didAddTab(
                                        Tab tab,
                                        @TabLaunchType int type,
                                        @TabCreationState int creationState,
                                        boolean markedForSelection) {
                                    createdCallback.notifyCalled();
                                    normalTabModel.removeObserver(this);
                                }
                            });
                });
        // Tablet has a new tab button. Phones should fall back to the menu.
        if (activity.isTablet()) {
            StripLayoutHelper strip =
                    TabStripUtils.getStripLayoutHelper(activity, /* incognito= */ false);
            CompositorButton newTabButton = strip.getNewTabButton();
            TabStripUtils.clickCompositorButton(newTabButton, instrumentation, activity);
            instrumentation.waitForIdleSync();
        } else {
            newTabFromMenu(
                    instrumentation, activity, /* incognito= */ false, /* waitForNtpLoad= */ false);
        }

        try {
            createdCallback.waitForCallback(null, 0, 1, 10, TimeUnit.SECONDS);
        } catch (TimeoutException e) {
            Assert.fail("Never received tab creation event");
        }
    }

    /**
     * Creates a new tab by invoking the 'New Tab' menu item.
     * <p>
     * Returns when the tab has been created and has finished navigating.
     */
    public static void newTabFromMenu(
            Instrumentation instrumentation, final ChromeActivity activity) {
        newTabFromMenu(instrumentation, activity, false, true);
    }

    /**
     * Creates a new tab by invoking the 'New Tab' or 'New Incognito Tab' menu item.
     * <p>
     * Returns when the tab has been created and has finished navigating.
     */
    public static void newTabFromMenu(
            Instrumentation instrumentation,
            final ChromeActivity activity,
            boolean incognito,
            boolean waitForNtpLoad) {
        final CallbackHelper createdCallback = new CallbackHelper();
        final CallbackHelper selectedCallback = new CallbackHelper();

        TabModel tabModel = activity.getTabModelSelector().getModel(incognito);
        TabModelObserver observer =
                new TabModelObserver() {
                    @Override
                    public void didAddTab(
                            Tab tab,
                            @TabLaunchType int type,
                            @TabCreationState int creationState,
                            boolean markedForSelection) {
                        createdCallback.notifyCalled();
                    }

                    @Override
                    public void didSelectTab(Tab tab, @TabSelectionType int type, int lastId) {
                        selectedCallback.notifyCalled();
                    }
                };
        ThreadUtils.runOnUiThreadBlocking(() -> tabModel.addObserver(observer));

        MenuUtils.invokeCustomMenuActionSync(
                instrumentation,
                activity,
                incognito ? R.id.new_incognito_tab_menu_id : R.id.new_tab_menu_id);

        try {
            createdCallback.waitForCallback(0);
        } catch (TimeoutException ex) {
            Assert.fail("Never received tab created event");
        }
        try {
            selectedCallback.waitForCallback(0);
        } catch (TimeoutException ex) {
            Assert.fail("Never received tab selected event");
        }
        ThreadUtils.runOnUiThreadBlocking(() -> tabModel.removeObserver(observer));

        Tab tab = activity.getActivityTab();
        waitForTabPageLoaded(tab, (String) null);
        if (waitForNtpLoad) NewTabPageTestUtils.waitForNtpLoaded(tab);
        instrumentation.waitForIdleSync();
        Log.d(TAG, "newTabFromMenu <<");
    }

    /**
     * New multiple tabs by invoking the 'new' menu item n times.
     * @param n The number of tabs you want to create.
     */
    public static void newTabsFromMenu(
            Instrumentation instrumentation, ChromeTabbedActivity activity, int n) {
        while (n > 0) {
            newTabFromMenu(instrumentation, activity);
            --n;
        }
    }

    /**
     * Creates a new tab in the specified model then waits for it to load.
     * <p>
     * Returns when the tab has been created and finishes loading.
     *
     * @return Newly created Tab object.
     */
    public static Tab fullyLoadUrlInNewTab(
            Instrumentation instrumentation,
            final ChromeTabbedActivity activity,
            final String url,
            final boolean incognito) {
        newTabFromMenu(instrumentation, activity, incognito, false);

        final Tab tab = activity.getActivityTab();
        waitForTabPageLoaded(
                tab,
                url,
                new Runnable() {
                    @Override
                    public void run() {
                        loadUrlOnUiThread(tab, url);
                    }
                });
        waitForInteractable(tab);
        return tab;
    }

    public static void loadUrlOnUiThread(final Tab tab, final String url) {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    tab.loadUrl(new LoadUrlParams(url));
                });
    }

    /** Ensure that at least some given number of tabs are open. */
    public static void ensureNumOpenTabs(
            Instrumentation instrumentation, ChromeTabbedActivity activity, int newCount) {
        int curCount = getNumOpenTabs(activity);
        if (curCount < newCount) {
            newTabsFromMenu(instrumentation, activity, newCount - curCount);
        }
    }

    /** Fetch the number of tabs open in the current model. */
    public static int getNumOpenTabs(final ChromeActivity activity) {
        return ThreadUtils.runOnUiThreadBlocking(
                new Callable<Integer>() {
                    @Override
                    public Integer call() {
                        return activity.getCurrentTabModel().getCount();
                    }
                });
    }

    /**
     * Closes the current tab through TabModelSelector.
     * <p>
     * Returns after the tab has been closed.
     */
    public static void closeCurrentTab(
            final Instrumentation instrumentation, final ChromeActivity activity) {
        closeTabWithAction(
                instrumentation,
                activity,
                new Runnable() {
                    @Override
                    public void run() {
                        instrumentation.runOnMainSync(
                                new Runnable() {
                                    @Override
                                    public void run() {
                                        TabModelUtils.closeCurrentTab(
                                                activity.getCurrentTabModel());
                                    }
                                });
                    }
                });
    }

    /** Closes a tab with the given action and waits for a tab closure to be observed. */
    public static void closeTabWithAction(
            Instrumentation instrumentation, final ChromeActivity activity, Runnable action) {
        final CallbackHelper closeCallback = new CallbackHelper();
        final TabModelObserver observer =
                new TabModelObserver() {
                    @Override
                    public void willCloseTab(Tab tab, boolean didCloseAlone) {
                        closeCallback.notifyCalled();
                    }
                };
        instrumentation.runOnMainSync(
                new Runnable() {
                    @Override
                    public void run() {
                        TabModelSelector selector = activity.getTabModelSelector();
                        for (TabModel tabModel : selector.getModels()) {
                            tabModel.addObserver(observer);
                        }
                    }
                });

        action.run();

        try {
            closeCallback.waitForCallback(0);
        } catch (TimeoutException e) {
            Assert.fail("Tab closed event was never received");
        }
        instrumentation.runOnMainSync(
                new Runnable() {
                    @Override
                    public void run() {
                        TabModelSelector selector = activity.getTabModelSelector();
                        for (TabModel tabModel : selector.getModels()) {
                            tabModel.removeObserver(observer);
                        }
                    }
                });
        instrumentation.waitForIdleSync();
        Log.d(TAG, "closeTabWithAction <<");
    }

    /** Close all tabs and waits for all tabs pending closure to be observed. */
    public static void closeAllTabs(
            Instrumentation instrumentation,
            ObservableSupplier<TabModelSelector> tabModelSelectorSupplier) {
        final CallbackHelper closeCallback = new CallbackHelper();
        final TabModelObserver observer =
                new TabModelObserver() {
                    @Override
                    public void multipleTabsPendingClosure(List<Tab> tabs, boolean isAllTabs) {
                        closeCallback.notifyCalled();
                    }
                };
        instrumentation.runOnMainSync(
                new Runnable() {
                    @Override
                    public void run() {
                        TabModelSelector selector = tabModelSelectorSupplier.get();
                        for (TabModel tabModel : selector.getModels()) {
                            tabModel.addObserver(observer);
                        }
                    }
                });

        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    tabModelSelectorSupplier.get().closeAllTabs();
                });

        try {
            closeCallback.waitForCallback(0);
        } catch (TimeoutException e) {
            Assert.fail("All tabs pending closure event was never received");
        }
        instrumentation.runOnMainSync(
                new Runnable() {
                    @Override
                    public void run() {
                        TabModelSelector selector = tabModelSelectorSupplier.get();
                        for (TabModel tabModel : selector.getModels()) {
                            tabModel.removeObserver(observer);
                        }
                    }
                });
        instrumentation.waitForIdleSync();
    }

    /**
     * @deprecated Transitory method, use {@link #closeAllTabs(Instrumentation,
     *     ObservableSupplier<TabModelSelector>)} instead. TODO(crbug.com/40191386): Remove this
     *     after the usages are migrated.
     */
    public static void closeAllTabs(
            Instrumentation instrumentation, final ChromeTabbedActivity activity) {
        closeAllTabs(instrumentation, activity.getTabModelSelectorSupplier());
    }

    /** Selects a tab with the given action and waits for the selection event to be observed. */
    public static void selectTabWithAction(
            Instrumentation instrumentation, final ChromeTabbedActivity activity, Runnable action) {
        final CallbackHelper selectCallback = new CallbackHelper();
        final TabModelObserver observer =
                new TabModelObserver() {
                    @Override
                    public void didSelectTab(Tab tab, @TabSelectionType int type, int lastId) {
                        selectCallback.notifyCalled();
                    }
                };
        instrumentation.runOnMainSync(
                new Runnable() {
                    @Override
                    public void run() {
                        TabModelSelector selector = activity.getTabModelSelector();
                        for (TabModel tabModel : selector.getModels()) {
                            tabModel.addObserver(observer);
                        }
                    }
                });

        action.run();

        try {
            selectCallback.waitForCallback(0);
        } catch (TimeoutException e) {
            Assert.fail("Tab selected event was never received");
        }
        instrumentation.runOnMainSync(
                new Runnable() {
                    @Override
                    public void run() {
                        TabModelSelector selector = activity.getTabModelSelector();
                        for (TabModel tabModel : selector.getModels()) {
                            tabModel.removeObserver(observer);
                        }
                    }
                });
    }

    /**
     * @param windowAndroid the WindowAndroid used to acquire the TabModelSelector.
     * @return The TabModelSelector used with the supplied WindowAndroid.
     */
    public static @NonNull TabModelSelector getTabModelSelector(WindowAndroid windowAndroid) {
        Assert.assertTrue(ThreadUtils.runningOnUiThread());
        Assert.assertNotNull(windowAndroid);

        final ObservableSupplier<TabModelSelector> supplier =
                TabModelSelectorSupplier.from(windowAndroid);
        Assert.assertNotNull(supplier);

        final TabModelSelector selector = supplier.get();
        Assert.assertNotNull(selector);
        return selector;
    }

    /**
     * @param tab The tab to retrieve Root ID for.
     * @return the Root ID for a supplied tab object.
     */
    public static int getRootId(Tab tab) {
        Assert.assertTrue(ThreadUtils.runningOnUiThread());
        return tab.getRootId();
    }

    /**
     * Groups together two tabs.
     * @param tab1 First tab to group.
     * @param tab2 Second tab to group.
     */
    public static void mergeTabsToGroup(Tab tab1, Tab tab2) {
        Assert.assertTrue(ThreadUtils.runningOnUiThread());

        // Verify that the two tabs do not belong with different models.
        Assert.assertEquals(tab1.isIncognito(), tab2.isIncognito());
        final TabModelSelector selector = getTabModelSelector(tab1.getWindowAndroid());
        final TabModelFilter filter =
                selector.getTabModelFilterProvider().getTabModelFilter(tab1.isIncognito());
        Assert.assertTrue(filter instanceof TabGroupModelFilter);
        TabGroupModelFilter groupingFilter = (TabGroupModelFilter) filter;

        groupingFilter.mergeTabsToGroup(tab1.getId(), tab2.getId());
        Assert.assertEquals(getRootId(tab1), getRootId(tab2));
    }

    /**
     * Long presses the view, selects an item from the context menu, and
     * asserts that a new tab is opened and is incognito if expectIncognito is true.
     * For use in testing long-press context menu options that open new tabs.
     *
     * @param testRule The {@link ChromeTabbedActivityTestRule} used to retrieve the currently
     *                 running activity.
     * @param view The {@link View} to long press.
     * @param contextMenuItemId The context menu item to select on the view.
     * @param expectIncognito Whether the opened tab is expected to be incognito.
     * @param expectedUrl The expected url for the new tab.
     */
    public static void invokeContextMenuAndOpenInANewTab(
            ChromeTabbedActivityTestRule testRule,
            View view,
            int contextMenuItemId,
            boolean expectIncognito,
            final String expectedUrl)
            throws ExecutionException {
        final CallbackHelper createdCallback = new CallbackHelper();
        final TabModel tabModel =
                testRule.getActivity().getTabModelSelector().getModel(expectIncognito);
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    tabModel.addObserver(
                            new TabModelObserver() {
                                @Override
                                public void didAddTab(
                                        Tab tab,
                                        @TabLaunchType int type,
                                        @TabCreationState int creationState,
                                        boolean markedForSelection) {
                                    if (TextUtils.equals(expectedUrl, tab.getUrl().getSpec())) {
                                        createdCallback.notifyCalled();
                                        tabModel.removeObserver(this);
                                    }
                                }
                            });
                });

        TestTouchUtils.performLongClickOnMainSync(
                InstrumentationRegistry.getInstrumentation(), view);
        Assert.assertTrue(
                InstrumentationRegistry.getInstrumentation()
                        .invokeContextMenuAction(testRule.getActivity(), contextMenuItemId, 0));

        try {
            createdCallback.waitForCallback(0);
        } catch (TimeoutException e) {
            Assert.fail("Never received tab creation event");
        }

        if (expectIncognito) {
            Assert.assertTrue(testRule.getActivity().getTabModelSelector().isIncognitoSelected());
        } else {
            Assert.assertFalse(testRule.getActivity().getTabModelSelector().isIncognitoSelected());
        }
    }

    /**
     * Long presses the view, selects an item from the context menu, and
     * asserts that a new tab is opened and is incognito if expectIncognito is true.
     * For use in testing long-press context menu options that open new tabs in a different
     * ChromeTabbedActivity instance.
     *
     * @param foregroundActivity The {@link ChromeTabbedActivity} currently in the foreground.
     * @param backgroundActivity The {@link ChromeTabbedActivity} currently in the background. The
     *                           new tab is expected to open in this activity.
     * @param view The {@link View} in the {@code foregroundActivity} to long press.
     * @param contextMenuItemId The context menu item to select on the view.
     * @param expectIncognito Whether the opened tab is expected to be incognito.
     * @param expectedUrl The expected url for the new tab.
     */
    public static void invokeContextMenuAndOpenInOtherWindow(
            ChromeTabbedActivity foregroundActivity,
            ChromeTabbedActivity backgroundActivity,
            View view,
            int contextMenuItemId,
            boolean expectIncognito,
            final String expectedUrl)
            throws ExecutionException {
        final CallbackHelper createdCallback = new CallbackHelper();
        final TabModel tabModel =
                backgroundActivity.getTabModelSelector().getModel(expectIncognito);
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    tabModel.addObserver(
                            new TabModelObserver() {
                                @Override
                                public void didAddTab(
                                        Tab tab,
                                        @TabLaunchType int type,
                                        @TabCreationState int creationState,
                                        boolean markedForSelection) {
                                    if (TextUtils.equals(expectedUrl, tab.getUrl().getSpec())) {
                                        createdCallback.notifyCalled();
                                        tabModel.removeObserver(this);
                                    }
                                }
                            });
                });

        TestTouchUtils.performLongClickOnMainSync(
                InstrumentationRegistry.getInstrumentation(), view);
        Assert.assertTrue(
                InstrumentationRegistry.getInstrumentation()
                        .invokeContextMenuAction(foregroundActivity, contextMenuItemId, 0));

        try {
            createdCallback.waitForCallback(0);
        } catch (TimeoutException e) {
            Assert.fail("Never received tab creation event");
        }

        if (expectIncognito) {
            Assert.assertTrue(backgroundActivity.getTabModelSelector().isIncognitoSelected());
        } else {
            Assert.assertFalse(backgroundActivity.getTabModelSelector().isIncognitoSelected());
        }
    }

    /**
     * Issues a fake notification about the renderer being killed.
     *
     * @param tab {@link Tab} instance where the target renderer resides.
     */
    public static void simulateRendererKilledForTesting(Tab tab) {
        TabWebContentsObserver observer = TabWebContentsObserver.get(tab);
        if (observer != null) {
            observer.simulateRendererKilledForTesting();
        }
    }

    public static void waitForTitle(Tab tab, String newTitle) {
        TabTitleObserver titleObserver = new TabTitleObserver(tab, newTitle);
        try {
            titleObserver.waitForTitleUpdate(TITLE_UPDATE_TIMEOUT_SECONDS);
        } catch (TimeoutException e) {
            Assert.fail(
                    String.format(
                            Locale.ENGLISH, "Tab title didn't update to %s in time.", newTitle));
        }
    }

    /**
     * @return Whether or not the loading and rendering of the page is done.
     */
    public static boolean isLoadingAndRenderingDone(Tab tab) {
        return isRendererReady(tab) && tab.getProgress() >= CONSIDERED_READY_LOAD_PERCENTAGE;
    }

    /**
     * @return Whether or not the tab has something valid to render.
     */
    public static boolean isRendererReady(Tab tab) {
        if (tab.getNativePage() != null) return true;
        WebContents webContents = tab.getWebContents();
        if (webContents == null) return false;

        RenderWidgetHostView rwhv = webContents.getRenderWidgetHostView();
        return rwhv != null && rwhv.isReady();
    }
}