chromium/chrome/android/javatests/src/org/chromium/chrome/browser/contextualsearch/ContextualSearchInstrumentationBase.java

// Copyright 2021 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.browser.contextualsearch;

import static org.junit.Assert.assertNotNull;

import static org.chromium.base.test.util.CriteriaHelper.DEFAULT_POLLING_INTERVAL;

import android.app.Activity;
import android.app.Instrumentation;
import android.app.Instrumentation.ActivityMonitor;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.graphics.Point;
import android.os.SystemClock;
import android.text.TextUtils;
import android.view.View;
import android.view.ViewConfiguration;
import android.widget.LinearLayout;

import androidx.test.platform.app.InstrumentationRegistry;

import org.hamcrest.Matchers;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.ClassRule;
import org.junit.Rule;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;

import org.chromium.base.ThreadUtils;
import org.chromium.base.test.util.Criteria;
import org.chromium.base.test.util.CriteriaHelper;
import org.chromium.base.test.util.JniMocker;
import org.chromium.chrome.browser.app.ChromeActivity;
import org.chromium.chrome.browser.compositor.bottombar.OverlayPanel.PanelState;
import org.chromium.chrome.browser.compositor.bottombar.OverlayPanel.StateChangeReason;
import org.chromium.chrome.browser.compositor.bottombar.OverlayPanelContentProgressObserver;
import org.chromium.chrome.browser.compositor.bottombar.OverlayPanelManager;
import org.chromium.chrome.browser.compositor.bottombar.OverlayPanelManagerWrapper;
import org.chromium.chrome.browser.compositor.bottombar.contextualsearch.ContextualSearchPanel;
import org.chromium.chrome.browser.compositor.layouts.LayoutManagerImpl;
import org.chromium.chrome.browser.content.WebContentsFactory;
import org.chromium.chrome.browser.contextualsearch.ContextualSearchFakeServer.ContextualSearchTestHost;
import org.chromium.chrome.browser.contextualsearch.ContextualSearchFakeServer.FakeResolveSearch;
import org.chromium.chrome.browser.contextualsearch.ContextualSearchFakeServer.FakeSlowResolveSearch;
import org.chromium.chrome.browser.firstrun.FirstRunStatus;
import org.chromium.chrome.browser.locale.LocaleManager;
import org.chromium.chrome.browser.locale.LocaleManagerDelegate;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.profiles.ProfileManager;
import org.chromium.chrome.browser.readaloud.ReadAloudController;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.test.ChromeTabbedActivityTestRule;
import org.chromium.chrome.test.batch.BlankCTATabInitialStateRule;
import org.chromium.components.embedder_support.view.ContentView;
import org.chromium.content_public.browser.SelectAroundCaretResult;
import org.chromium.content_public.browser.SelectionClient;
import org.chromium.content_public.browser.SelectionPopupController;
import org.chromium.content_public.browser.WebContents;
import org.chromium.content_public.browser.test.util.DOMUtils;
import org.chromium.content_public.browser.test.util.TestSelectionPopupController;
import org.chromium.content_public.browser.test.util.TouchCommon;
import org.chromium.content_public.browser.test.util.WebContentsUtils;
import org.chromium.net.test.EmbeddedTestServer;
import org.chromium.ui.base.ViewAndroidDelegate;
import org.chromium.ui.touch_selection.SelectionEventType;

import java.util.concurrent.TimeoutException;

/** This is a base class for various Contextual Search instrumentation tests. */
public class ContextualSearchInstrumentationBase {
    @ClassRule
    public static final ChromeTabbedActivityTestRule sActivityTestRule =
            new ChromeTabbedActivityTestRule();

    @Rule
    public final BlankCTATabInitialStateRule mInitialStateRule =
            new BlankCTATabInitialStateRule(sActivityTestRule, false);

    @Rule public JniMocker mocker = new JniMocker();

    @Mock ContextualSearchManager.Natives mContextualSearchManagerJniMock;

    // --------------------------------------------------------------------------------------------

    /** ContextualSearchPanel wrapper that prevents native calls. */
    protected static class ContextualSearchPanelWrapper extends ContextualSearchPanel {
        public ContextualSearchPanelWrapper(
                Context context,
                LayoutManagerImpl layoutManager,
                OverlayPanelManager panelManager,
                Profile profile) {
            super(
                    context,
                    layoutManager,
                    panelManager,
                    null,
                    null,
                    profile,
                    null,
                    0,
                    null,
                    true,
                    null,
                    sActivityTestRule.getActivity().getEdgeToEdgeControllerSupplierForTesting());
        }

        @Override
        public void peekPanel(@StateChangeReason int reason) {
            setHeightForTesting(1);
            super.peekPanel(reason);
        }

        @Override
        public void setBasePageTextControlsVisibility(boolean visible) {}
    }

    // --------------------------------------------------------------------------------------------

    /** ContextualSearchManager wrapper that prevents network requests and most native calls. */
    protected static class ContextualSearchManagerWrapper extends ContextualSearchManager {
        public ContextualSearchManagerWrapper(ChromeActivity activity) {
            super(
                    activity,
                    ProfileManager.getLastUsedRegularProfile(),
                    null,
                    activity.getRootUiCoordinatorForTesting().getScrimCoordinator(),
                    activity.getActivityTabProvider(),
                    activity.getFullscreenManager(),
                    activity.getBrowserControlsManager(),
                    activity.getWindowAndroid(),
                    activity.getTabModelSelector(),
                    () -> activity.getLastUserInteractionTime(),
                    activity.getEdgeToEdgeControllerSupplierForTesting());
            setSelectionController(new MockCSSelectionController(activity, this));
            Profile profile = ProfileManager.getLastUsedRegularProfile();
            WebContents webContents = WebContentsFactory.createWebContents(profile, false, false);
            ContentView cv = ContentView.createContentView(activity, webContents);
            webContents.setDelegates(
                    null,
                    ViewAndroidDelegate.createBasicDelegate(cv),
                    null,
                    activity.getWindowAndroid(),
                    WebContents.createDefaultInternalsHolder());
            SelectionPopupController selectionPopupController =
                    WebContentsUtils.createSelectionPopupController(webContents);
            selectionPopupController.setSelectionClient(this.getContextualSearchSelectionClient());

            MockContextualSearchPolicy policy =
                    new MockContextualSearchPolicy(profile, getSelectionController());
            setContextualSearchPolicy(policy);
        }

        @Override
        public void startSearchTermResolutionRequest(
                String selection, boolean isExactResolve, ContextualSearchContext searchContext) {
            // Skip native calls and immediately "resolve" the search term.
            onSearchTermResolutionResponse(
                    true,
                    200,
                    selection,
                    selection,
                    "",
                    "",
                    false,
                    0,
                    10,
                    "",
                    "",
                    "",
                    "",
                    QuickActionCategory.NONE,
                    "",
                    "",
                    0,
                    "");
        }

        /**
         * @return A stubbed SelectionPopupController for mocking text selection.
         */
        public StubbedSelectionPopupController getBaseSelectionPopupController() {
            return (StubbedSelectionPopupController)
                    getSelectionController().getSelectionPopupController();
        }
    }

    // --------------------------------------------------------------------------------------------

    /** Selection controller that mocks out anything to do with a WebContents. */
    private static class MockCSSelectionController extends ContextualSearchSelectionController {
        private StubbedSelectionPopupController mPopupController;

        public MockCSSelectionController(
                ChromeActivity activity, ContextualSearchSelectionHandler handler) {
            super(activity, handler, activity.getActivityTabProvider());
            mPopupController = new StubbedSelectionPopupController();
        }

        @Override
        protected SelectionPopupController getSelectionPopupController() {
            return mPopupController;
        }
    }

    // --------------------------------------------------------------------------------------------

    /** A SelectionPopupController that has some methods stubbed out for testing. */
    protected static final class StubbedSelectionPopupController
            extends TestSelectionPopupController {
        private String mCurrentText;
        private boolean mIsFocusedNodeEditable;

        public StubbedSelectionPopupController() {}

        public void setIsFocusedNodeEditableForTest(boolean isFocusedNodeEditable) {
            mIsFocusedNodeEditable = isFocusedNodeEditable;
        }

        @Override
        public boolean isFocusedNodeEditable() {
            return mIsFocusedNodeEditable;
        }

        @Override
        public String getSelectedText() {
            return mCurrentText;
        }

        public void setSelectedText(String string) {
            mCurrentText = string;
        }
    }

    // --------------------------------------------------------------------------------------------

    /** Trigger text selection on the contextual search manager. */
    protected void mockLongpressText(String text) {
        mContextualSearchManager.getBaseSelectionPopupController().setSelectedText(text);
        ThreadUtils.runOnUiThreadBlocking(
                () ->
                        mContextualSearchClient.onSelectionEvent(
                                SelectionEventType.SELECTION_HANDLES_SHOWN, 0, 0));
    }

    /** Trigger text selection on the contextual search manager. */
    protected void mockTapText(String text) {
        mContextualSearchManager.getBaseSelectionPopupController().setSelectedText(text);
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    mContextualSearchManager.getGestureStateListener().onTouchDown();
                    mContextualSearchManager.onShowUnhandledTapUIIfNeeded(0, 0);
                });
    }

    /** Trigger empty space tap. */
    protected void mockTapEmptySpace() {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    mContextualSearchManager.onShowUnhandledTapUIIfNeeded(0, 0);
                    mContextualSearchClient.onSelectionEvent(
                            SelectionEventType.SELECTION_HANDLES_CLEARED, 0, 0);
                });
    }

    /** Generates a call indicating that surrounding text and selection range are available. */
    protected void generateTextSurroundingSelectionAvailable() {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    // It only makes sense to send placeholder data here because we can't easily
                    // control what's in the native context.
                    mContextualSearchManager.onTextSurroundingSelectionAvailable(
                            "UTF-8", "unused", 0, 0);
                });
    }

    /**
     * Generates an ACK for the SelectWordAroundCaret native call, which indicates that the select
     * action has completed with the given result.
     */
    protected void generateSelectWordAroundCaretAck() {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    // It only makes sense to send placeholder data here because we can't easily
                    // control what's in the native context.
                    mContextualSearchClient.selectAroundCaretAck(
                            new SelectAroundCaretResult(0, 0, 0, 0));
                });
    }

    // --------------------------------------------------------------------------------------------

    /**
     * The DOM node for the word "search" on the test page, which causes a plain search response
     * with the Search Term "Search" from the Fake server.
     */
    protected static final String SEARCH_NODE = "search";

    protected static final String SEARCH_NODE_TERM = "Search";

    /**
     * The DOM node for the word "intelligence" on the test page, which causes a search response for
     * the Search Term "Intelligence" and also includes Related Searches suggestions.
     */
    protected static final String RELATED_SEARCHES_NODE = "intelligence";

    private static final String TAG = "CSIBase";
    private static final int TEST_TIMEOUT = 1500;
    private static final int TEST_EXPECTED_FAILURE_TIMEOUT = 1000;

    private static final int PANEL_INTERACTION_RETRY_DELAY_MS = 200;

    private static final int DOUBLE_TAP_DELAY_MULTIPLIER = 3;

    // Search request URL paths and CGI parameters.
    private static final String LOW_PRIORITY_SEARCH_ENDPOINT = "/s?";
    private static final String NORMAL_PRIORITY_SEARCH_ENDPOINT = "/search?";
    private static final String LOW_PRIORITY_INVALID_SEARCH_ENDPOINT = "/s/invalid";
    private static final String CONTEXTUAL_SEARCH_PREFETCH_PARAM = "&pf=c";

    protected static final String EXTERNAL_APP_URL =
            "intent://test/#Intent;scheme=externalappscheme;end";

    protected ContextualSearchManager mManager;
    protected ContextualSearchPolicy mPolicy;
    protected ContextualSearchPanel mPanel;
    protected ContextualSearchFakeServer mFakeServer;
    protected EmbeddedTestServer mTestServer;

    protected String mTestPage = "/chrome/test/data/android/contextualsearch/simple_test.html";

    protected ContextualSearchManagerWrapper mContextualSearchManager;
    protected OverlayPanelManagerWrapper mPanelManager;
    private SelectionClient mContextualSearchClient;
    private LayoutManagerImpl mLayoutManager;

    protected ActivityMonitor mActivityMonitor;
    protected ContextualSearchSelectionController mSelectionController;
    private ContextualSearchInstrumentationTestHost mTestHost;

    private float mDpToPx;

    // State for an individual test.
    private FakeSlowResolveSearch mLatestSlowResolveSearch;

    @Before
    public void setUp() throws Exception {
        final ChromeActivity activity = sActivityTestRule.getActivity();
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    FirstRunStatus.setFirstRunFlowComplete(true);

                    mPanelManager = new OverlayPanelManagerWrapper();
                    mPanelManager.setContainerView(new LinearLayout(activity));
                    mContextualSearchManager = new ContextualSearchManagerWrapper(activity);
                    mContextualSearchClient =
                            mContextualSearchManager.getContextualSearchSelectionClient();

                    LocaleManager.getInstance()
                            .setDelegateForTest(
                                    new LocaleManagerDelegate() {
                                        @Override
                                        public boolean needToCheckForSearchEnginePromo() {
                                            return false;
                                        }
                                    });
                });

        mTestServer = sActivityTestRule.getTestServer();

        sActivityTestRule.loadUrl(mTestServer.getURL(mTestPage));
        // DOMUtils sometimes hits the wrong node due to an incorrect page scale factor,
        // so wait until that is set. https://crbug.com/1327063
        sActivityTestRule.assertWaitForPageScaleFactorMatch(1.0f);

        mManager = sActivityTestRule.getActivity().getContextualSearchManagerForTesting();
        mTestHost = new ContextualSearchInstrumentationTestHost();

        Assert.assertNotNull(mManager);
        mPanel = (ContextualSearchPanel) mManager.getContextualSearchPanel();
        Assert.assertNotNull(mPanel);

        mSelectionController = mManager.getSelectionController();
        mPolicy = mManager.getContextualSearchPolicy();
        mPolicy.overrideDecidedStateForTesting(true);

        mFakeServer =
                new ContextualSearchFakeServer(
                        mPolicy,
                        mTestHost,
                        mManager,
                        mManager.getOverlayPanelContentDelegate(),
                        new OverlayPanelContentProgressObserver(),
                        sActivityTestRule.getActivity());

        mPanel.setOverlayPanelContentFactory(mFakeServer);
        mManager.setNetworkCommunicator(mFakeServer);
        mPolicy.setNetworkCommunicator(mFakeServer);

        registerFakeSearches();

        IntentFilter filter = new IntentFilter(Intent.ACTION_VIEW);
        filter.addCategory(Intent.CATEGORY_BROWSABLE);
        filter.addDataScheme("externalappscheme");
        mActivityMonitor =
                InstrumentationRegistry.getInstrumentation()
                        .addMonitor(
                                filter,
                                new Instrumentation.ActivityResult(Activity.RESULT_OK, null),
                                true);

        mDpToPx = sActivityTestRule.getActivity().getResources().getDisplayMetrics().density;

        // Set the test Features map for all tests regardless of whether they are parameterized.
        // Non-parameterized tests typically override this setting by calling setTestFeatures
        // again.
        // If Related Searches is enabled we need to also set that it's OK to send page content.
        mPolicy.overrideAllowSendingPageUrlForTesting(true);

        MockitoAnnotations.openMocks(this);
    }

    @After
    public void tearDown() throws Exception {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    FirstRunStatus.setFirstRunFlowComplete(false);

                    if (mManager != null) mManager.dismissContextualSearchBar();
                    if (mPanel != null) mPanel.closePanel(StateChangeReason.UNKNOWN, false);
                });
        if (mActivityMonitor != null) {
            InstrumentationRegistry.getInstrumentation().removeMonitor(mActivityMonitor);
        }
        mActivityMonitor = null;
        mLatestSlowResolveSearch = null;
        if (mPolicy != null) {
            mPolicy.overrideAllowSendingPageUrlForTesting(false);
        }
    }

    /** Allows the fake server to call into this host to drive actions when simulating a search. */
    private class ContextualSearchInstrumentationTestHost implements ContextualSearchTestHost {
        @Override
        public void triggerNonResolve(String nodeId) throws TimeoutException {
            boolean previousOptedInState = mPolicy.overrideDecidedStateForTesting(false);
            clickWordNode(nodeId);
            mPolicy.overrideDecidedStateForTesting(previousOptedInState);
        }

        @Override
        public void triggerResolve(String nodeId) throws TimeoutException {
            boolean previousOptedInState = mPolicy.overrideDecidedStateForTesting(true);
            clickWordNode(nodeId);
            mPolicy.overrideDecidedStateForTesting(previousOptedInState);
        }

        @Override
        public void waitForSelectionToBe(final String text) {
            CriteriaHelper.pollInstrumentationThread(
                    () -> {
                        Criteria.checkThat(getSelectedText(), Matchers.is(text));
                    },
                    TEST_TIMEOUT,
                    DEFAULT_POLLING_INTERVAL);
        }

        @Override
        public void waitForSearchTermResolutionToStart(final FakeResolveSearch search) {
            CriteriaHelper.pollInstrumentationThread(
                    () -> {
                        return search.didStartSearchTermResolution();
                    },
                    "Fake Search Term Resolution never started.",
                    TEST_TIMEOUT,
                    DEFAULT_POLLING_INTERVAL);
        }

        @Override
        public void waitForSearchTermResolutionToFinish(final FakeResolveSearch search) {
            CriteriaHelper.pollInstrumentationThread(
                    () -> {
                        return search.didFinishSearchTermResolution();
                    },
                    "Fake Search was never ready.",
                    TEST_TIMEOUT,
                    DEFAULT_POLLING_INTERVAL);
        }

        @Override
        public ContextualSearchPanel getPanel() {
            return mPanel;
        }
    }

    // ============================================================================================
    // Helper Functions and Methods.
    // TODO(donnd): Mark protected and use these in ContextualSearchManagerTest.
    // ============================================================================================

    /** Triggers the panel to show in the peeking state. */
    void triggerPanelPeek() throws Exception {
        // TODO(donnd): is it better to use the resolve or non-resolve implementation?
        simulateResolveSearch(SEARCH_NODE);
    }

    protected interface ThrowingRunnable {
        void run() throws TimeoutException;
    }

    protected void clearSelection() {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    SelectionPopupController.fromWebContents(sActivityTestRule.getWebContents())
                            .clearSelection();
                });
    }

    // ============================================================================================
    // Public API
    // ============================================================================================

    /**
     * Simulates a long-press on the given node without waiting for the panel to respond.
     *
     * @param nodeId A string containing the node ID.
     */
    public void longPressNodeWithoutWaiting(String nodeId) throws TimeoutException {
        Tab tab = sActivityTestRule.getActivity().getActivityTab();
        DOMUtils.longPressNode(tab.getWebContents(), nodeId);
    }

    /**
     * Simulates a long-press on the given node and waits for the panel to peek.
     *
     * @param nodeId A string containing the node ID.
     */
    public void longPressNode(String nodeId) throws TimeoutException {
        longPressNodeWithoutWaiting(nodeId);
        waitForPanelToPeek();
    }

    /**
     * Simulates a resolving trigger on the given node but does not wait for the panel to peek.
     *
     * @param nodeId A string containing the node ID.
     */
    protected void triggerResolve(String nodeId) throws TimeoutException {
        mTestHost.triggerResolve(nodeId);
    }

    /**
     * Simulates a non-resolve trigger on the given node and waits for the panel to peek.
     *
     * @param nodeId A string containing the node ID.
     */
    protected void triggerNonResolve(String nodeId) throws TimeoutException {
        mTestHost.triggerNonResolve(nodeId);
    }

    /**
     * Waits for the selected text string to be the given string, and asserts.
     *
     * @param text The string to wait for the selection to become.
     */
    protected void waitForSelectionToBe(final String text) {
        mTestHost.waitForSelectionToBe(text);
    }

    /**
     * Asserts that the action bar does or does not become visible in response to a selection.
     *
     * @param visible Whether the Action Bar must become visible or not.
     */
    protected void assertWaitForSelectActionBarVisible(final boolean visible) {
        CriteriaHelper.pollUiThread(
                () -> {
                    Criteria.checkThat(
                            getSelectionPopupController().isSelectActionBarShowing(),
                            Matchers.is(visible));
                },
                TEST_TIMEOUT,
                DEFAULT_POLLING_INTERVAL);
    }

    protected SelectionPopupController getSelectionPopupController() {
        return SelectionPopupController.fromWebContents(sActivityTestRule.getWebContents());
    }

    /**
     * Long-press a node without completing the action, by keeping the touch down by not letting up.
     *
     * @param nodeId The ID of the node to touch
     * @return A time stamp to use with {@link #longPressExtendSelection}
     * @see #longPressExtendSelection
     */
    public long longPressNodeWithoutUp(String nodeId) throws TimeoutException {
        long downTime = SystemClock.uptimeMillis();
        Tab tab = sActivityTestRule.getActivity().getActivityTab();
        DOMUtils.longPressNodeWithoutUp(tab.getWebContents(), nodeId, downTime);
        waitForSelectActionBarVisible();
        waitForPanelToPeek();
        return downTime;
    }

    /**
     * Extends a Long-press selection by completing a drag action.
     *
     * @param startNodeId The ID of the node that has already been touched
     * @param endNodeId The ID of the node that the touch should be extended to
     * @param downTime A time stamp returned by {@link #longPressNodeWithoutUp}
     * @see #longPressNodeWithoutUp
     */
    public void longPressExtendSelection(String startNodeId, String endNodeId, long downTime)
            throws TimeoutException {
        // TODO(donnd): figure out why we need this one line here, and why the selection does not
        // match our expected nodes!
        longPressNodeWithoutUp("term");

        // Drag to the specified position by a DOM node id.
        int stepCount = 100;
        Tab tab = sActivityTestRule.getActivity().getActivityTab();
        DOMUtils.dragNodeTo(tab.getWebContents(), startNodeId, endNodeId, stepCount, downTime);
        DOMUtils.dragNodeEnd(tab.getWebContents(), endNodeId, downTime);

        // Make sure the selection controller knows we did a drag.
        // TODO(donnd): figure out how to reliably simulate a drag on all platforms.
        float unused = 0.0f;
        @SelectionEventType int dragStoppedEvent = SelectionEventType.SELECTION_HANDLE_DRAG_STOPPED;
        ThreadUtils.runOnUiThreadBlocking(
                () -> mSelectionController.handleSelectionEvent(dragStoppedEvent, unused, unused));

        waitForSelectActionBarVisible();
    }

    /**
     * Simulates a click on the given node.
     *
     * @param nodeId A string containing the node ID.
     */
    public void clickNode(String nodeId) throws TimeoutException {
        Tab tab = sActivityTestRule.getActivity().getActivityTab();
        DOMUtils.clickNode(tab.getWebContents(), nodeId);
    }

    /**
     * Runs the given Runnable in the main thread.
     *
     * @param runnable The Runnable.
     */
    public void runOnMainSync(Runnable runnable) {
        InstrumentationRegistry.getInstrumentation().runOnMainSync(runnable);
    }

    // ============================================================================================
    // Fake Searches Helpers
    // ============================================================================================

    /**
     * Simulates a non-resolving search.
     *
     * @param nodeId The id of the node to be triggered.
     */
    protected void simulateNonResolveSearch(String nodeId)
            throws InterruptedException, TimeoutException {
        ContextualSearchFakeServer.FakeNonResolveSearch search =
                mFakeServer.getFakeNonResolveSearch(nodeId);
        search.simulate();
        waitForPanelToPeek();
    }

    /**
     * Simulates a resolve-triggering search.
     *
     * @param nodeId The id of the node to be tapped.
     */
    protected FakeResolveSearch simulateResolveSearch(String nodeId)
            throws InterruptedException, TimeoutException {
        return simulateResolvableSearchAndAssertResolveAndPreload(nodeId, true);
    }

    /** Simulates a resolve search on the default node on the page. */
    protected void simulateResolveSearch() throws Exception {
        simulateResolveSearch(SEARCH_NODE);
    }

    /**
     * Simulates a resolve-triggering gesture that may or may not actually resolve. If the gesture
     * should Resolve, the resolve and preload are asserted, and vice versa.
     *
     * @param nodeId The id of the node to be tapped.
     * @param isResolveExpected Whether a resolve is expected or not. Enforce by asserting.
     */
    protected FakeResolveSearch simulateResolvableSearchAndAssertResolveAndPreload(
            String nodeId, boolean isResolveExpected)
            throws InterruptedException, TimeoutException {
        FakeResolveSearch search = mFakeServer.getFakeResolveSearch(nodeId);
        assertNotNull("Could not find FakeResolveSearch for node ID:" + nodeId, search);
        search.simulate();
        waitForPanelToPeek();
        if (isResolveExpected) {
            assertLoadedSearchTermMatches(search.getSearchTerm());
        } else {
            assertSearchTermNotRequested();
            assertNoSearchesLoaded();
            assertNoWebContents();
        }
        return search;
    }

    /**
     * Simulates a resolving search with slow server response.
     *
     * @param nodeId The id of the node to be triggered.
     */
    protected void simulateSlowResolveSearch(String nodeId)
            throws InterruptedException, TimeoutException {
        mLatestSlowResolveSearch = mFakeServer.getFakeSlowResolveSearch(nodeId);
        assertNotNull(
                "Could not find FakeSlowResolveSearch for node ID:" + nodeId,
                mLatestSlowResolveSearch);
        mLatestSlowResolveSearch.simulate();
        waitForPanelToPeek();
    }

    /**
     * Simulates a slow response for the most recent {@link FakeSlowResolveSearch} set up by calling
     * simulateSlowResolveSearch.
     */
    protected void simulateSlowResolveFinished() throws InterruptedException, TimeoutException {
        // Allow the slow Resolution to finish, waiting for it to complete.
        mLatestSlowResolveSearch.finishResolve();
        assertLoadedSearchTermMatches(mLatestSlowResolveSearch.getSearchTerm());
    }

    /** Registers all fake searches to be used in tests. */
    private void registerFakeSearches() throws Exception {
        mFakeServer.registerFakeSearches();
    }

    // ============================================================================================
    // Fake Response
    // TODO(donnd): remove these methods and use the new infrastructure instead.
    // ============================================================================================

    /** Posts a fake response on the Main thread. */
    private final class FakeResponseOnMainThread implements Runnable {
        private final ResolvedSearchTerm mResolvedSearchTerm;

        public FakeResponseOnMainThread(ResolvedSearchTerm resolvedSearchTerm) {
            mResolvedSearchTerm = resolvedSearchTerm;
        }

        @Override
        public void run() {
            mFakeServer.handleSearchTermResolutionResponse(mResolvedSearchTerm);
        }
    }

    /**
     * Fakes a server response with the parameters given and startAdjust and endAdjust equal to 0.
     * {@See ContextualSearchManager#handleSearchTermResolutionResponse}.
     */
    protected void fakeResponse(
            boolean isNetworkUnavailable,
            int responseCode,
            String searchTerm,
            String displayText,
            String alternateTerm,
            boolean doPreventPreload) {
        fakeResponse(
                new ResolvedSearchTerm.Builder(
                                isNetworkUnavailable,
                                responseCode,
                                searchTerm,
                                displayText,
                                alternateTerm,
                                doPreventPreload)
                        .build());
    }

    /**
     * Fakes a server response with the parameters given. {@See
     * ContextualSearchManager#handleSearchTermResolutionResponse}.
     */
    protected void fakeResponse(ResolvedSearchTerm resolvedSearchTerm) {
        if (mFakeServer.getSearchTermRequested() != null) {
            InstrumentationRegistry.getInstrumentation()
                    .runOnMainSync(new FakeResponseOnMainThread(resolvedSearchTerm));
        }
    }

    // ============================================================================================
    // Content Helpers
    // ============================================================================================

    /**
     * @return The Panel's WebContents.
     */
    protected WebContents getPanelWebContents() {
        return mPanel.getWebContents();
    }

    /**
     * @return Whether the Panel's WebContents is visible.
     */
    private boolean isWebContentsVisible() {
        return mFakeServer.isContentVisible();
    }

    /** Asserts that the Panel's WebContents is created. */
    protected void assertWebContentsCreated() {
        Assert.assertNotNull(getPanelWebContents());
    }

    /** Asserts that the Panel's WebContents is not created. */
    protected void assertNoWebContents() {
        Assert.assertNull(getPanelWebContents());
    }

    /** Asserts that the Panel's WebContents is visible. */
    protected void assertWebContentsVisible() {
        Assert.assertTrue(isWebContentsVisible());
    }

    /** Asserts that the Panel's WebContents.onShow() method was never called. */
    protected void assertNeverCalledWebContentsOnShow() {
        Assert.assertFalse(mFakeServer.didEverCallWebContentsOnShow());
    }

    /** Asserts that the Panel's WebContents is created */
    protected void assertWebContentsCreatedButNeverMadeVisible() {
        assertWebContentsCreated();
        Assert.assertFalse(isWebContentsVisible());
        assertNeverCalledWebContentsOnShow();
    }

    // ============================================================================================
    // Assertions for different states
    // ============================================================================================

    void assertPeekingPanelNonResolve() {
        assertLoadedNoUrl();
    }

    // TODO(donnd): flesh out the assertions below.
    void assertClosedPanelNonResolve() {}

    void assertPanelNeverOpened() {
        // Check that we recorded a histogram entry for not-seen.
    }

    void assertPeekingPanelResolve() {
        assertLoadedLowPriorityUrl();
    }

    /** Asserts that the expanded panel did a resolve for the given {@code searchTerm}. */
    void assertExpandedPanelResolve(String searchTerm) {
        assertLoadedSearchTermMatches(searchTerm);
    }

    void assertExpandedPanelNonResolve() {
        assertSearchTermNotRequested();
    }

    void assertClosedPanelResolve() {}

    // ============================================================================================

    /**
     * Fakes navigation of the Content View to the URL that was previously requested.
     *
     * @param isFailure whether the request resulted in a failure.
     */
    protected void fakeContentViewDidNavigate(boolean isFailure) {
        String url = mFakeServer.getLoadedUrl();
        mManager.getOverlayPanelContentDelegate()
                .onMainFrameNavigation(url, false, isFailure, false);
    }

    /**
     * Simulates a click on the given word node. Waits for the bar to peek. TODO(donnd): rename to
     * include the waitForPanelToPeek semantic, or rename clickNode to clickNodeWithoutWaiting.
     *
     * @param nodeId A string containing the node ID.
     */
    protected void clickWordNode(String nodeId) throws TimeoutException {
        clickNode(nodeId);
        waitForPanelToFreshlyPeek();
    }

    /**
     * Simulates a simple gesture that could trigger a resolve on the given node in the given tab.
     *
     * @param tab The tab that contains the node to trigger (must be frontmost).
     * @param nodeId A string containing the node ID.
     */
    public void triggerNode(Tab tab, String nodeId) throws TimeoutException {
        DOMUtils.longPressNode(tab.getWebContents(), nodeId);
    }

    /**
     * @return The selected text.
     */
    protected String getSelectedText() {
        return mSelectionController.getSelectedText();
    }

    /**
     * Asserts that the loaded search term matches the provided value.
     *
     * @param searchTerm The provided search term.
     */
    protected void assertLoadedSearchTermMatches(String searchTerm) {
        boolean doesMatch = false;
        String loadedUrl = mFakeServer.getLoadedUrl();
        doesMatch = loadedUrl != null && loadedUrl.contains("q=" + searchTerm);
        String message =
                loadedUrl == null ? "but there was no loaded URL!" : "in URL: " + loadedUrl;
        Assert.assertTrue(
                "Expected to find searchTerm '" + searchTerm + "', " + message, doesMatch);
    }

    /** Asserts that the given parameters are present in the most recently loaded URL. */
    protected void assertContainsParameters(String... terms) {
        Assert.assertNotNull("Fake server didn't load a SERP URL", mFakeServer.getLoadedUrl());
        for (String term : terms) {
            Assert.assertTrue(
                    "Expected search term not found:" + term,
                    mFakeServer.getLoadedUrl().contains(term));
        }
    }

    /** Asserts that a Search Term has been requested. */
    protected void assertSearchTermRequested() {
        Assert.assertNotNull(mFakeServer.getSearchTermRequested());
    }

    /** Asserts that there has not been any Search Term requested. */
    private void assertSearchTermNotRequested() {
        Assert.assertNull(mFakeServer.getSearchTermRequested());
    }

    /** Asserts that the panel is currently closed or in an undefined state. */
    void assertPanelClosedOrUndefined() {
        boolean success = false;
        if (mPanel == null) {
            success = true;
        } else {
            @PanelState int panelState = mPanel.getPanelState();
            success = panelState == PanelState.CLOSED || panelState == PanelState.UNDEFINED;
        }
        Assert.assertTrue(
                "Expected the panel to be closed or undefined but it was in state: "
                        + mPanel.getPanelState(),
                success);
    }

    /** Asserts that no URL has been loaded in the Overlay Panel. */
    protected void assertLoadedNoUrl() {
        Assert.assertTrue(
                "Requested a search or preload when none was expected!",
                mFakeServer.getLoadedUrl() == null);
    }

    /** Asserts that a low-priority URL has been loaded in the Overlay Panel. */
    protected void assertLoadedLowPriorityUrl() {
        String message =
                "Expected a low priority search request URL, but got "
                        + (mFakeServer.getLoadedUrl() != null
                                ? mFakeServer.getLoadedUrl()
                                : "null");
        Assert.assertTrue(
                message,
                mFakeServer.getLoadedUrl() != null
                        && mFakeServer.getLoadedUrl().contains(LOW_PRIORITY_SEARCH_ENDPOINT));
        Assert.assertTrue(
                "Low priority request does not have the required prefetch parameter!",
                mFakeServer.getLoadedUrl() != null
                        && mFakeServer.getLoadedUrl().contains(CONTEXTUAL_SEARCH_PREFETCH_PARAM));
    }

    /**
     * Asserts that a low-priority URL that is intentionally invalid has been loaded in the Overlay
     * Panel (in order to produce an error).
     */
    protected void assertLoadedLowPriorityInvalidUrl() {
        String message =
                "Expected a low priority invalid search request URL, but got "
                        + (String.valueOf(mFakeServer.getLoadedUrl()));
        Assert.assertTrue(
                message,
                mFakeServer.getLoadedUrl() != null
                        && mFakeServer
                                .getLoadedUrl()
                                .contains(LOW_PRIORITY_INVALID_SEARCH_ENDPOINT));
        Assert.assertTrue(
                "Low priority request does not have the required prefetch parameter!",
                mFakeServer.getLoadedUrl() != null
                        && mFakeServer.getLoadedUrl().contains(CONTEXTUAL_SEARCH_PREFETCH_PARAM));
    }

    /** Asserts that a normal priority URL has been loaded in the Overlay Panel. */
    protected void assertLoadedNormalPriorityUrl() {
        String message =
                "Expected a normal priority search request URL, but got "
                        + (mFakeServer.getLoadedUrl() != null
                                ? mFakeServer.getLoadedUrl()
                                : "null");
        Assert.assertTrue(
                message,
                mFakeServer.getLoadedUrl() != null
                        && mFakeServer.getLoadedUrl().contains(NORMAL_PRIORITY_SEARCH_ENDPOINT));
        Assert.assertTrue(
                "Normal priority request should not have the prefetch parameter, but did!",
                mFakeServer.getLoadedUrl() != null
                        && !mFakeServer.getLoadedUrl().contains(CONTEXTUAL_SEARCH_PREFETCH_PARAM));
    }

    /**
     * Waits for a Normal priority URL to be loaded, or asserts that the load never happened. This
     * is needed when we test with a live internet connection and an invalid url fails to load (as
     * expected. See crbug.com/682953 for background.
     */
    protected void waitForNormalPriorityUrlLoaded() {
        CriteriaHelper.pollInstrumentationThread(
                () -> {
                    Criteria.checkThat(mFakeServer.getLoadedUrl(), Matchers.notNullValue());
                    Criteria.checkThat(
                            mFakeServer.getLoadedUrl(),
                            Matchers.containsString(NORMAL_PRIORITY_SEARCH_ENDPOINT));
                },
                TEST_TIMEOUT,
                DEFAULT_POLLING_INTERVAL);
    }

    /**
     * Asserts that no URLs have been loaded in the Overlay Panel since the last {@link
     * ContextualSearchFakeServer#reset}.
     */
    protected void assertNoSearchesLoaded() {
        Assert.assertEquals(0, mFakeServer.getLoadedUrlCount());
        assertLoadedNoUrl();
    }

    /**
     * Asserts that a Search Term has been requested.
     *
     * @param isExactResolve Whether the Resolve request must be exact (non-expanding).
     */
    protected void assertExactResolve(boolean isExactResolve) {
        Assert.assertEquals(isExactResolve, mFakeServer.getIsExactResolve());
    }

    /**
     * Waits for the Search Panel (the Search Bar) to peek up from the bottom, and asserts that it
     * did peek.
     */
    protected void waitForPanelToPeek() {
        waitForPanelToEnterState(PanelState.PEEKED);
    }

    /**
     * Waits for the Search Panel (the Search Bar) to peek up from the bottom, and asserts that it
     * did peek. Ignores an existing Search Panel that peeked.
     */
    protected void waitForPanelToFreshlyPeek() {
        int lastPeekSequence = mPanel.getLastPeekSequence();
        final @PanelState int state = PanelState.PEEKED;
        CriteriaHelper.pollUiThread(
                () -> {
                    Criteria.checkThat(mPanel, Matchers.notNullValue());
                    Criteria.checkThat(mPanel.getPanelState(), Matchers.is(state));
                    Criteria.checkThat(
                            mPanel.getLastPeekSequence(), Matchers.not(lastPeekSequence));
                    Criteria.checkThat(mPanel.isHeightAnimationRunning(), Matchers.is(false));
                },
                TEST_TIMEOUT,
                DEFAULT_POLLING_INTERVAL);
    }

    /** Waits for the Search Panel to expand, and asserts that it did expand. */
    protected void waitForPanelToExpand() {
        waitForPanelToEnterState(PanelState.EXPANDED);
    }

    /** Waits for the Search Panel to maximize, and asserts that it did maximize. */
    protected void waitForPanelToMaximize() {
        waitForPanelToEnterState(PanelState.MAXIMIZED);
    }

    /** Waits for the Search Panel to close, and asserts that it did close. */
    protected void waitForPanelToClose() {
        waitForPanelToEnterState(PanelState.CLOSED);
    }

    /**
     * Waits for the Search Panel to enter the given {@code PanelState} and assert.
     *
     * @param state The {@link PanelState} to wait for.
     */
    private void waitForPanelToEnterState(final @PanelState int state) {
        CriteriaHelper.pollUiThread(
                () -> {
                    Criteria.checkThat(mPanel, Matchers.notNullValue());
                    Criteria.checkThat(mPanel.getPanelState(), Matchers.is(state));
                    Criteria.checkThat(mPanel.isHeightAnimationRunning(), Matchers.is(false));
                },
                TEST_TIMEOUT,
                DEFAULT_POLLING_INTERVAL);
    }

    /**
     * Asserts that the panel is still in the given state and continues to stay that way for a
     * while. Waits for a reasonable amount of time for the panel to change to a different state,
     * and verifies that it did not change state while this method is executing. Note that it's
     * quite possible for the panel to transition through some other state and back to the initial
     * state before this method is called without that being detected, because this method only
     * monitors state during its own execution.
     *
     * @param initialState The initial state of the panel at the beginning of an operation that
     *     should not change the panel state.
     * @throws InterruptedException
     */
    protected void assertPanelStillInState(final @PanelState int initialState)
            throws InterruptedException {
        boolean didChangeState = false;
        long startTime = SystemClock.uptimeMillis();
        while (!didChangeState
                && SystemClock.uptimeMillis() - startTime < TEST_EXPECTED_FAILURE_TIMEOUT) {
            Thread.sleep(DEFAULT_POLLING_INTERVAL);
            didChangeState = mPanel.getPanelState() != initialState;
        }
        Assert.assertFalse(didChangeState);
    }

    /**
     * Shorthand for a common sequence: 1) Waits for gesture processing, 2) Waits for the panel to
     * close, 3) Asserts that there is no selection and that the panel closed.
     */
    protected void waitForGestureToClosePanelAndAssertNoSelection() {
        waitForPanelToClose();
        assertPanelClosedOrUndefined();
        Assert.assertTrue(TextUtils.isEmpty(getSelectedText()));
    }

    /**
     * Waits for the selection to be empty. Use this method any time a test repeatedly establishes
     * and dissolves a selection to ensure that the selection has been completely dissolved before
     * simulating the next selection event. This is needed because the renderer's notification of a
     * selection going away is async, and a subsequent tap may think there's a current selection
     * until it has been dissolved.
     */
    private void waitForSelectionEmpty() {
        CriteriaHelper.pollUiThread(
                () -> mSelectionController.isSelectionEmpty(),
                "Selection never empty.",
                TEST_TIMEOUT,
                DEFAULT_POLLING_INTERVAL);
    }

    /** Waits for the panel to close and then waits for the selection to dissolve. */
    protected void waitForPanelToCloseAndSelectionEmpty() {
        waitForPanelToClose();
        waitForSelectionEmpty();
    }

    protected void waitToPreventDoubleTapRecognition() throws InterruptedException {
        // Avoid issues with double-tap detection by ensuring sequential taps
        // aren't treated as such. Double-tapping can also select words much as
        // longpress, in turn showing the pins and preventing contextual tap
        // refinement from nearby taps. The double-tap timeout is sufficiently
        // short that this shouldn't conflict with tap refinement by the user.
        int doubleTapTimeout = ViewConfiguration.getDoubleTapTimeout();
        Thread.sleep(doubleTapTimeout * DOUBLE_TAP_DELAY_MULTIPLIER);
    }

    /**
     * Generate a fling sequence from the given start/end X,Y percentages, for the given steps.
     * Works in either landscape or portrait orientation.
     */
    private void fling(float startX, float startY, float endX, float endY, int stepCount) {
        Point size = new Point();
        sActivityTestRule.getActivity().getWindowManager().getDefaultDisplay().getSize(size);
        float dragStartX = size.x * startX;
        float dragEndX = size.x * endX;
        float dragStartY = size.y * startY;
        float dragEndY = size.y * endY;
        long downTime = SystemClock.uptimeMillis();
        TouchCommon.dragStart(sActivityTestRule.getActivity(), dragStartX, dragStartY, downTime);
        TouchCommon.dragTo(
                sActivityTestRule.getActivity(),
                dragStartX,
                dragEndX,
                dragStartY,
                dragEndY,
                stepCount,
                downTime);
        TouchCommon.dragEnd(sActivityTestRule.getActivity(), dragEndX, dragEndY, downTime);
    }

    /**
     * Generate a swipe sequence from the given start/end X,Y percentages, for the given steps.
     * Works in either landscape or portrait orientation.
     */
    private void swipe(float startX, float startY, float endX, float endY, int stepCount) {
        Point size = new Point();
        sActivityTestRule.getActivity().getWindowManager().getDefaultDisplay().getSize(size);
        float dragStartX = size.x * startX;
        float dragEndX = size.x * endX;
        float dragStartY = size.y * startY;
        float dragEndY = size.y * endY;
        int halfCount = stepCount / 2;
        long downTime = SystemClock.uptimeMillis();
        TouchCommon.dragStart(sActivityTestRule.getActivity(), dragStartX, dragStartY, downTime);
        TouchCommon.dragTo(
                sActivityTestRule.getActivity(),
                dragStartX,
                dragEndX,
                dragStartY,
                dragEndY,
                halfCount,
                downTime);
        // Generate events in the stationary end position in order to simulate a "pause" in
        // the movement, therefore preventing this gesture from being interpreted as a fling.
        TouchCommon.dragTo(
                sActivityTestRule.getActivity(),
                dragEndX,
                dragEndX,
                dragEndY,
                dragEndY,
                halfCount,
                downTime);
        TouchCommon.dragEnd(sActivityTestRule.getActivity(), dragEndX, dragEndY, downTime);
    }

    /** Flings the panel up to its expanded state. */
    protected void flingPanelUp() {
        fling(0.5f, 0.95f, 0.5f, 0.55f, 1000);
    }

    /** Swipes the panel down to its peeked state. */
    protected void swipePanelDown() {
        swipe(0.5f, 0.55f, 0.5f, 0.95f, 1000);
    }

    /** Scrolls the base page. */
    protected void scrollBasePage() {
        fling(0.f, 0.75f, 0.f, 0.7f, 100);
    }

    /** Taps the base page near the top. */
    protected void tapBasePageToClosePanel() {
        // TODO(donnd): This is not reliable. Find a better approach.
        // This taps on the panel in an area that will be selected if the "intelligence" node has
        // been tap-selected, and that will cause it to be long-press selected.
        // We use the far right side to prevent simulating a tap on top of an
        // existing long-press selection (the pins are a tap target). This might not work on RTL.
        // We are using y == 0.35f because otherwise it will fail for long press cases.
        // It might be better to get the position of the Panel and tap just about outside
        // the Panel. I suspect some Flaky tests are caused by this problem (ones involving
        // long press and trying to close with the bar peeking, with a long press selection
        // established).
        tapBasePage(0.95f, 0.35f);
        waitForPanelToClose();
    }

    /** Taps the base page at the given x, y position. */
    private void tapBasePage(float x, float y) {
        View root = sActivityTestRule.getActivity().getWindow().getDecorView().getRootView();
        x *= root.getWidth();
        y *= root.getHeight();
        TouchCommon.singleClickView(root, (int) x, (int) y);
    }

    /** Expands the panel by directly asking the panel to expand. */
    protected void expandPanel() throws TimeoutException {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    mPanel.notifyBarTouched(0);
                    if (mFakeServer.getContentsObserver() != null) {
                        mFakeServer.getContentsObserver().wasShown();
                    }
                    mPanel.animatePanelToState(
                            PanelState.EXPANDED,
                            StateChangeReason.UNKNOWN,
                            PANEL_INTERACTION_RETRY_DELAY_MS);
                    float tapX = (mPanel.getOffsetX() + mPanel.getWidth()) / 2f;
                    float tapY = (mPanel.getOffsetY() + mPanel.getBarContainerHeight()) / 2f;
                    mPanel.handleBarClick(tapX, tapY);
                });
    }

    /** Expands the panel and asserts that it did actually expand. */
    protected void expandPanelAndAssert() throws TimeoutException {
        expandPanel();
        waitForPanelToExpand();
    }

    /** Force the Panel to peek. */
    protected void peekPanel() {
        // TODO(donnd): use a consistent method of running these test tasks, and it's probably
        // best to use ThreadUtils.runOnUiThreadBlocking as done elsewhere in this file.
        InstrumentationRegistry.getInstrumentation()
                .runOnMainSync(
                        () -> {
                            mPanel.peekPanel(StateChangeReason.UNKNOWN);
                        });
        waitForPanelToPeek();
    }

    /** Force the Panel to maximize, and wait for it to do so. */
    protected void maximizePanel() {
        // TODO(donnd): use a consistent method of running these test tasks, and it's probably
        // best to use ThreadUtils.runOnUiThreadBlocking as done elsewhere in this file.
        InstrumentationRegistry.getInstrumentation()
                .runOnMainSync(
                        () -> {
                            mPanel.maximizePanel(StateChangeReason.UNKNOWN);
                        });
        waitForPanelToMaximize();
    }

    /** Fakes a response to the Resolve request. */
    protected void fakeAResponse() {
        fakeResponse(false, 200, "states", "United States Intelligence", "alternate-term", false);
        waitForPanelToPeek();
        assertLoadedLowPriorityUrl();
        assertContainsParameters("states", "alternate-term");
    }

    /** Force the Panel to handle a click on open-in-a-new-tab icon. */
    protected void forceOpenTabIconClick() {
        InstrumentationRegistry.getInstrumentation()
                .runOnMainSync(
                        () -> {
                            mPanel.handleBarClick(
                                    mPanel.getOpenTabIconX() + mPanel.getOpenTabIconDimension() / 2,
                                    mPanel.getBarHeight() / 2);
                        });
    }

    /** Force the Panel to close. */
    protected void closePanel() {
        InstrumentationRegistry.getInstrumentation()
                .runOnMainSync(
                        () -> {
                            mPanel.closePanel(StateChangeReason.UNKNOWN, false);
                        });
    }

    /** Waits for the Action Bar to be visible in response to a selection. */
    protected void waitForSelectActionBarVisible() {
        assertWaitForSelectActionBarVisible(true);
    }

    /** Updates Read Aloud Controller's active playback tab. */
    protected void changeReadAloudActivePlaybackTab() {
        ReadAloudController readAloudController =
                sActivityTestRule.getActivity().getReadAloudControllerForTesting();

        ThreadUtils.runOnUiThreadBlocking(
                () ->
                        readAloudController.setActivePlaybackTab(
                                sActivityTestRule.getActivity().getActivityTab()));
    }
}