chromium/chrome/android/javatests/src/org/chromium/chrome/browser/payments/PaymentRequestTestRule.java

// Copyright 2017 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.payments;

import android.os.Handler;
import android.view.KeyEvent;
import android.view.View;
import android.view.inputmethod.EditorInfo;
import android.widget.EditText;
import android.widget.Spinner;
import android.widget.TextView;

import androidx.annotation.IntDef;

import org.hamcrest.Matchers;
import org.junit.Assert;

import org.chromium.base.ThreadUtils;
import org.chromium.base.task.PostTask;
import org.chromium.base.task.TaskTraits;
import org.chromium.base.test.util.CallbackHelper;
import org.chromium.base.test.util.Criteria;
import org.chromium.base.test.util.CriteriaHelper;
import org.chromium.base.test.util.CriteriaNotSatisfiedException;
import org.chromium.base.test.util.UrlUtils;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.autofill.CardUnmaskPrompt;
import org.chromium.chrome.browser.autofill.CardUnmaskPrompt.CardUnmaskObserverForTest;
import org.chromium.chrome.browser.autofill.editors.EditorObserverForTest;
import org.chromium.chrome.browser.payments.ChromePaymentRequestFactory.ChromePaymentRequestDelegateImpl;
import org.chromium.chrome.browser.payments.ChromePaymentRequestFactory.ChromePaymentRequestDelegateImplObserverForTest;
import org.chromium.chrome.browser.payments.ui.PaymentRequestSection.OptionSection;
import org.chromium.chrome.browser.payments.ui.PaymentRequestSection.OptionSection.OptionRow;
import org.chromium.chrome.browser.payments.ui.PaymentRequestUI;
import org.chromium.chrome.browser.payments.ui.PaymentRequestUI.PaymentRequestObserverForTest;
import org.chromium.chrome.test.ChromeTabbedActivityTestRule;
import org.chromium.components.payments.InputProtector;
import org.chromium.components.payments.PayerData;
import org.chromium.components.payments.PaymentApp;
import org.chromium.components.payments.PaymentAppFactoryDelegate;
import org.chromium.components.payments.PaymentAppFactoryInterface;
import org.chromium.components.payments.PaymentAppService;
import org.chromium.components.payments.PaymentRequestService;
import org.chromium.components.payments.PaymentRequestService.PaymentRequestServiceObserverForTest;
import org.chromium.components.payments.test_support.FakeClock;
import org.chromium.content_public.browser.WebContents;
import org.chromium.content_public.browser.test.util.DOMUtils;
import org.chromium.content_public.browser.test.util.JavaScriptUtils;
import org.chromium.payments.mojom.PaymentDetailsModifier;
import org.chromium.payments.mojom.PaymentItem;
import org.chromium.payments.mojom.PaymentMethodData;
import org.chromium.payments.mojom.PaymentOptions;
import org.chromium.payments.mojom.PaymentShippingOption;
import org.chromium.ui.modaldialog.ModalDialogProperties;
import org.chromium.ui.modelutil.PropertyModel;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicReference;

/** Custom ActivityTestRule for integration test for payments. */
/*package*/ class PaymentRequestTestRule extends ChromeTabbedActivityTestRule
        implements PaymentRequestObserverForTest,
                PaymentRequestServiceObserverForTest,
                ChromePaymentRequestDelegateImplObserverForTest,
                CardUnmaskObserverForTest,
                EditorObserverForTest {
    private static final long SAFE_INPUT_DELAY =
            InputProtector.POTENTIALLY_UNINTENDED_INPUT_THRESHOLD;

    @IntDef({AppPresence.NO_APPS, AppPresence.HAVE_APPS})
    @Retention(RetentionPolicy.SOURCE)
    /* package */ @interface AppPresence {
        /** Flag for a factory without payment apps. */
        static final int NO_APPS = 0;

        /** Flag for a factory with payment apps. */
        static final int HAVE_APPS = 1;
    }

    @IntDef({AppSpeed.FAST_APP, AppSpeed.SLOW_APP})
    @Retention(RetentionPolicy.SOURCE)
    /* package */ @interface AppSpeed {
        /** Flag for installing a payment app that responds to its invocation fast. */
        static final int FAST_APP = 0;

        /** Flag for installing a payment app that responds to its invocation slowly. */
        static final int SLOW_APP = 1;
    }

    @IntDef({FactorySpeed.FAST_FACTORY, FactorySpeed.SLOW_FACTORY})
    @Retention(RetentionPolicy.SOURCE)
    /* package */ @interface FactorySpeed {
        /** Flag for a factory that immediately creates a payment app. */
        static final int FAST_FACTORY = 0;

        /** Flag for a factory that creates a payment app with a delay. */
        static final int SLOW_FACTORY = 1;
    }

    /** The expiration month dropdown index for December. */
    /* package */ static final int DECEMBER = 11;

    /** The expiration year dropdown index for the next year. */
    /* package */ static final int NEXT_YEAR = 1;

    /**
     * The billing address dropdown index for the first billing address. Index 0 is for the "Select"
     * hint.
     */
    /* package */ static final int FIRST_BILLING_ADDRESS = 1;

    /** Command line flag to enable experimental web platform features in tests. */
    /* package */ static final String ENABLE_EXPERIMENTAL_WEB_PLATFORM_FEATURES =
            "enable-experimental-web-platform-features";

    private final PaymentsCallbackHelper<PaymentRequestUI> mShowCalled;
    private final PaymentsCallbackHelper<PaymentRequestUI> mReadyForInput;
    private final PaymentsCallbackHelper<PaymentRequestUI> mReadyToPay;
    private final PaymentsCallbackHelper<PaymentRequestUI> mSelectionChecked;
    private final PaymentsCallbackHelper<PaymentRequestUI> mResultReady;
    private final PaymentsCallbackHelper<CardUnmaskPrompt> mReadyForUnmaskInput;
    private final PaymentsCallbackHelper<CardUnmaskPrompt> mReadyToUnmask;
    private final PaymentsCallbackHelper<CardUnmaskPrompt> mUnmaskValidationDone;
    private final PaymentsCallbackHelper<CardUnmaskPrompt> mSubmitRejected;
    private final CallbackHelper mReadyToEdit;
    private final CallbackHelper mEditorValidationError;
    private final CallbackHelper mEditorTextUpdate;
    private final CallbackHelper mDismissed;
    private final CallbackHelper mUnableToAbort;
    private final CallbackHelper mBillingAddressChangeProcessed;
    private final CallbackHelper mShowFailed;
    private final CallbackHelper mCanMakePaymentQueryResponded;
    private final CallbackHelper mHasEnrolledInstrumentQueryResponded;
    private final CallbackHelper mExpirationMonthChange;
    private final CallbackHelper mPaymentResponseReady;
    private final CallbackHelper mCompleteHandled;
    private final CallbackHelper mRendererClosedMojoConnection;
    private ChromePaymentRequestDelegateImpl mChromePaymentRequestDelegateImpl;
    private PaymentRequestUI mUI;
    private FakeClock mClock;
    private InputProtector mInputProtector;

    private final boolean mDelayStartActivity;
    private boolean mAutoAdvanceInputProtectorClock;

    private final AtomicReference<WebContents> mWebContentsRef;

    private final String mTestFilePath;

    private CardUnmaskPrompt mCardUnmaskPrompt;

    /**
     * Creates an instance of PaymentRequestTestRule.
     *
     * @param testFileName The file name of an test page in //components/test/data/payments,
     *     'about:blank', or a data url which starts with 'data:'.
     */
    /* package */ PaymentRequestTestRule(String testFileName) {
        this(testFileName, false);
    }

    /**
     * Creates an instance of PaymentRequestTestRule.
     *
     * @param testFileName The file name of an test page in //components/test/data/payments,
     *     'about:blank', or a data url which starts with 'data:'.
     * @param delayStartActivity Whether to delay the start of the main activity. When true, {@link
     *     #startMainActivityWithURL()} needs to be called to start the main activity; otherwise,
     *     the main activity would start automatically.
     */
    /* package */ PaymentRequestTestRule(String testFileName, boolean delayStartActivity) {
        this(testFileName, /* pathPrefix= */ "components/test/data/payments/", delayStartActivity);
    }

    /**
     * Creates an instance of PaymentRequestTestRule with a test page, which is specified by
     * pathPrefix and testFileName combined into a path relative to the repository root. For
     * example, if testFileName is "merchant.html", pathPrefix is "components/test/data/payments/",
     * the method would look for a test page at "components/test/data/payments/merchant.html".
     *
     * @param testFileName The file name of the test page.
     * @param pathPrefix The prefix path to testFileName.
     * @param delayStartActivity Whether to delay the start of the main activity.
     */
    private PaymentRequestTestRule(
            String testFilePath, String pathPrefix, boolean delayStartActivity) {
        super();
        mShowCalled = new PaymentsCallbackHelper<>();
        mReadyForInput = new PaymentsCallbackHelper<>();
        mReadyToPay = new PaymentsCallbackHelper<>();
        mSelectionChecked = new PaymentsCallbackHelper<>();
        mResultReady = new PaymentsCallbackHelper<>();
        mReadyForUnmaskInput = new PaymentsCallbackHelper<>();
        mReadyToUnmask = new PaymentsCallbackHelper<>();
        mUnmaskValidationDone = new PaymentsCallbackHelper<>();
        mSubmitRejected = new PaymentsCallbackHelper<>();
        mReadyToEdit = new CallbackHelper();
        mEditorValidationError = new CallbackHelper();
        mEditorTextUpdate = new CallbackHelper();
        mDismissed = new CallbackHelper();
        mUnableToAbort = new CallbackHelper();
        mBillingAddressChangeProcessed = new CallbackHelper();
        mExpirationMonthChange = new CallbackHelper();
        mPaymentResponseReady = new CallbackHelper();
        mShowFailed = new CallbackHelper();
        mCanMakePaymentQueryResponded = new CallbackHelper();
        mHasEnrolledInstrumentQueryResponded = new CallbackHelper();
        mCompleteHandled = new CallbackHelper();
        mRendererClosedMojoConnection = new CallbackHelper();
        mWebContentsRef = new AtomicReference<>();
        if (testFilePath.equals("about:blank") || testFilePath.startsWith("data:")) {
            mTestFilePath = testFilePath;
        } else {
            mTestFilePath = UrlUtils.getIsolatedTestFilePath(pathPrefix + testFilePath);
        }
        mDelayStartActivity = delayStartActivity;
        mAutoAdvanceInputProtectorClock = true;
        mClock = new FakeClock();
        mInputProtector = new InputProtector(mClock);
    }

    /* package */ void setObserversAndWaitForInitialPageLoad() throws TimeoutException {
        try {
            // TODO(crbug.com/40728764): Figure out what these tests need to wait on to not be flaky
            // instead of sleeping.
            Thread.sleep(2000);
        } catch (Exception ex) {
        }
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    mWebContentsRef.set(getActivity().getCurrentWebContents());
                    PaymentRequestUI.setEditorObserverForTest(PaymentRequestTestRule.this);
                    PaymentRequestUI.setPaymentRequestObserverForTest(PaymentRequestTestRule.this);
                    PaymentRequestService.setObserverForTest(PaymentRequestTestRule.this);
                    ChromePaymentRequestFactory.setChromePaymentRequestDelegateImplObserverForTest(
                            PaymentRequestTestRule.this);
                    CardUnmaskPrompt.setObserverForTest(PaymentRequestTestRule.this);
                });
        assertWaitForPageScaleFactorMatch(0.5f);
    }

    /* package */ PaymentsCallbackHelper<PaymentRequestUI> getShowCalled() {
        return mShowCalled;
    }

    /* package */ PaymentsCallbackHelper<PaymentRequestUI> getReadyForInput() {
        return mReadyForInput;
    }

    /* package */ PaymentsCallbackHelper<PaymentRequestUI> getReadyToPay() {
        return mReadyToPay;
    }

    /* package */ PaymentsCallbackHelper<PaymentRequestUI> getSelectionChecked() {
        return mSelectionChecked;
    }

    /* package */ PaymentsCallbackHelper<PaymentRequestUI> getResultReady() {
        return mResultReady;
    }

    /* package */ PaymentsCallbackHelper<CardUnmaskPrompt> getReadyForUnmaskInput() {
        return mReadyForUnmaskInput;
    }

    /* package */ PaymentsCallbackHelper<CardUnmaskPrompt> getReadyToUnmask() {
        return mReadyToUnmask;
    }

    /* package */ PaymentsCallbackHelper<CardUnmaskPrompt> getUnmaskValidationDone() {
        return mUnmaskValidationDone;
    }

    /* package */ PaymentsCallbackHelper<CardUnmaskPrompt> getSubmitRejected() {
        return mSubmitRejected;
    }

    /* package */ CallbackHelper getReadyToEdit() {
        return mReadyToEdit;
    }

    /* package */ CallbackHelper getEditorValidationError() {
        return mEditorValidationError;
    }

    /* package */ CallbackHelper getEditorTextUpdate() {
        return mEditorTextUpdate;
    }

    /* package */ CallbackHelper getDismissed() {
        return mDismissed;
    }

    /* package */ CallbackHelper getUnableToAbort() {
        return mUnableToAbort;
    }

    /* package */ CallbackHelper getBillingAddressChangeProcessed() {
        return mBillingAddressChangeProcessed;
    }

    /* package */ CallbackHelper getShowFailed() {
        return mShowFailed;
    }

    /* package */ CallbackHelper getCanMakePaymentQueryResponded() {
        return mCanMakePaymentQueryResponded;
    }

    /* package */ CallbackHelper getHasEnrolledInstrumentQueryResponded() {
        return mHasEnrolledInstrumentQueryResponded;
    }

    /* package */ CallbackHelper getExpirationMonthChange() {
        return mExpirationMonthChange;
    }

    /* package */ CallbackHelper getPaymentResponseReady() {
        return mPaymentResponseReady;
    }

    /* package */ CallbackHelper getCompleteHandled() {
        return mCompleteHandled;
    }

    /* package */ CallbackHelper getRendererClosedMojoConnection() {
        return mRendererClosedMojoConnection;
    }

    /* package */ PaymentRequestUI getPaymentRequestUI() {
        return mUI;
    }

    /* package */ void triggerUIAndWait(
            String nodeId, PaymentsCallbackHelper<PaymentRequestUI> helper)
            throws TimeoutException {
        clickNodeAndWait(nodeId, helper);
    }

    /* package */ void retryPaymentRequest(String validationErrors, CallbackHelper helper)
            throws TimeoutException {
        int callCount = helper.getCallCount();
        JavaScriptUtils.executeJavaScriptAndWaitForResult(
                mWebContentsRef.get(), "retry(" + validationErrors + ");");
        helper.waitForCallback(callCount);
    }

    /**
     * Executes a snippet of JavaScript code in the current tab, and returns the result of the
     * snippet. The JavaScript code is run without a user gesture, and any async result (i.e.,
     * Promise) is not waited for.
     */
    /* package */ String executeJavaScriptAndWaitForResult(String script) throws TimeoutException {
        return JavaScriptUtils.executeJavaScriptAndWaitForResult(mWebContentsRef.get(), script);
    }

    /**
     * Executes a snippet of JavaScript code in the current tab, and waits for a given UI event to
     * occur. The JavaScript code is run with a user gesture present, and any async result (i.e.,
     * Promise) is not waited for.
     */
    /* package */ void runJavaScriptAndWaitForUIEvent(String code, CallbackHelper helper)
            throws TimeoutException {
        int callCount = helper.getCallCount();
        runJavaScriptCodeWithUserGestureInCurrentTab(code);
        helper.waitForCallback(callCount);
    }

    /**
     * Executes a snippet of JavaScript code in the current tab, and waits for the promise it
     * returns to settle with some value or to reject with an error message; returning the result as
     * a String in either case. The JavaScript code is run with a user gesture present.
     *
     * @param promiseCode a JavaScript snippet that will return a promise
     */
    /* package */ String runJavaScriptAndWaitForPromise(String promiseCode)
            throws TimeoutException {
        String code =
                promiseCode
                        + ".then(result => domAutomationController.send(result))"
                        + ".catch(error => domAutomationController.send(error));";
        return JavaScriptUtils.runJavascriptWithUserGestureAndAsyncResult(getWebContents(), code);
    }

    /** Clicks on an HTML node. */
    /* package */ void clickNodeAndWait(String nodeId, CallbackHelper helper)
            throws TimeoutException {
        int callCount = helper.getCallCount();
        clickNode(nodeId);
        helper.waitForCallback(callCount);
    }

    /** Clicks on an HTML node. */
    /* package */ void clickNode(String nodeId) throws TimeoutException {
        DOMUtils.waitForNonZeroNodeBounds(mWebContentsRef.get(), nodeId);
        DOMUtils.clickNode(mWebContentsRef.get(), nodeId);
    }

    /** Clicks on an element in the payments UI. */
    /* package */ void clickAndWait(int resourceId, CallbackHelper helper) throws TimeoutException {
        int callCount = helper.getCallCount();
        CriteriaHelper.pollUiThread(
                () -> {
                    boolean canClick = mUI.isAcceptingUserInput();
                    if (canClick) mUI.getDialogForTest().findViewById(resourceId).performClick();
                    Criteria.checkThat(canClick, Matchers.is(true));
                });
        helper.waitForCallback(callCount);
    }

    /** Clicks on an element in the "Order summary" section of the payments UI. */
    /* package */ void clickInOrderSummaryAndWait(CallbackHelper helper) throws TimeoutException {
        int callCount = helper.getCallCount();
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    mUI.getOrderSummarySectionForTest()
                            .findViewById(R.id.payments_section)
                            .performClick();
                });
        helper.waitForCallback(callCount);
    }

    /** Clicks on an element in the "Shipping address" section of the payments UI. */
    /* package */ void clickInShippingAddressAndWait(final int resourceId, CallbackHelper helper)
            throws TimeoutException {
        int callCount = helper.getCallCount();
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    mUI.getShippingAddressSectionForTest().findViewById(resourceId).performClick();
                });
        helper.waitForCallback(callCount);
    }

    /** Clicks on an element in the "Payment" section of the payments UI. */
    /* package */ void clickInPaymentMethodAndWait(final int resourceId, CallbackHelper helper)
            throws TimeoutException {
        int callCount = helper.getCallCount();
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    mUI.getPaymentMethodSectionForTest().findViewById(resourceId).performClick();
                });
        helper.waitForCallback(callCount);
    }

    /** Clicks on an element in the "Contact Info" section of the payments UI. */
    /* package */ void clickInContactInfoAndWait(final int resourceId, CallbackHelper helper)
            throws TimeoutException {
        int callCount = helper.getCallCount();
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    mUI.getContactDetailsSectionForTest().findViewById(resourceId).performClick();
                });
        helper.waitForCallback(callCount);
    }

    /** Clicks on an element in the editor UI. */
    /* package */ void clickInEditorAndWait(final int resourceId, CallbackHelper helper)
            throws TimeoutException {
        int callCount = helper.getCallCount();
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    mUI.getEditorDialog().findViewById(resourceId).performClick();
                });
        helper.waitForCallback(callCount);
    }

    /* package */ void clickAndroidBackButtonInEditorAndWait(CallbackHelper helper)
            throws TimeoutException {
        int callCount = helper.getCallCount();
        PostTask.runOrPostTask(
                TaskTraits.UI_DEFAULT,
                () -> {
                    mUI.getEditorDialog()
                            .dispatchKeyEvent(
                                    new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_BACK));
                    mUI.getEditorDialog()
                            .dispatchKeyEvent(
                                    new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_BACK));
                });
        helper.waitForCallback(callCount);
    }

    /** Clicks on a button in the card unmask UI. */
    /* package */ void clickCardUnmaskButtonAndWait(final int dialogButtonId, CallbackHelper helper)
            throws TimeoutException {
        int callCount = helper.getCallCount();
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    PropertyModel model = mCardUnmaskPrompt.getDialogForTest();
                    model.get(ModalDialogProperties.CONTROLLER).onClick(model, dialogButtonId);
                });
        helper.waitForCallback(callCount);
    }

    /** Gets the retry error message. */
    /* package */ String getRetryErrorMessage() {
        return ThreadUtils.runOnUiThreadBlocking(
                () ->
                        ((TextView) mUI.getDialogForTest().findViewById(R.id.retry_error))
                                .getText()
                                .toString());
    }

    /** Gets the button state for the shipping summary section. */
    /* package */ int getShippingAddressSectionButtonState() {
        return ThreadUtils.runOnUiThreadBlocking(
                () -> mUI.getShippingAddressSectionForTest().getEditButtonState());
    }

    /** Gets the button state for the contact details section. */
    /* package */ int getContactDetailsButtonState() {
        return ThreadUtils.runOnUiThreadBlocking(
                () -> mUI.getContactDetailsSectionForTest().getEditButtonState());
    }

    /** Returns the label of the payment app at the specified |index|. */
    /* package */ String getPaymentAppLabel(final int index) {
        return ThreadUtils.runOnUiThreadBlocking(
                () ->
                        ((OptionSection) mUI.getPaymentMethodSectionForTest())
                                .getOptionLabelsForTest(index)
                                .getText()
                                .toString());
    }

    /** Returns the label of the selected payment app. */
    /* package */ String getSelectedPaymentAppLabel() {
        return ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    OptionSection section = ((OptionSection) mUI.getPaymentMethodSectionForTest());
                    int size = section.getNumberOfOptionLabelsForTest();
                    for (int i = 0; i < size; i++) {
                        if (section.getOptionRowAtIndex(i).isChecked()) {
                            return section.getOptionRowAtIndex(i).getLabelText().toString();
                        }
                    }
                    return null;
                });
    }

    /** Returns the total amount in order summary section. */
    /* package */ String getOrderSummaryTotal() {
        return ThreadUtils.runOnUiThreadBlocking(
                () -> mUI.getOrderSummaryTotalTextViewForTest().getText().toString());
    }

    /** Returns the amount text corresponding to the line item at the specified |index|. */
    /* package */ String getLineItemAmount(int index) {
        return ThreadUtils.runOnUiThreadBlocking(
                () ->
                        mUI.getOrderSummarySectionForTest()
                                .getLineItemAmountForTest(index)
                                .getText()
                                .toString()
                                .trim());
    }

    /** Returns the amount text corresponding to the line item at the specified |index|. */
    /* package */ int getNumberOfLineItems() {
        return ThreadUtils.runOnUiThreadBlocking(
                () -> mUI.getOrderSummarySectionForTest().getNumberOfLineItemsForTest());
    }

    /**
     * Returns the label corresponding to the contact detail suggestion at the specified
     * |suggestionIndex|.
     */
    /* package */ String getContactDetailsSuggestionLabel(final int suggestionIndex) {
        return ThreadUtils.runOnUiThreadBlocking(
                () ->
                        ((OptionSection) mUI.getContactDetailsSectionForTest())
                                .getOptionLabelsForTest(suggestionIndex)
                                .getText()
                                .toString());
    }

    /** Returns the number of payment apps. */
    /* package */ int getNumberOfPaymentApps() {
        return ThreadUtils.runOnUiThreadBlocking(
                () ->
                        ((OptionSection) mUI.getPaymentMethodSectionForTest())
                                .getNumberOfOptionLabelsForTest());
    }

    /**
     * Returns the label corresponding to the payment method suggestion at the specified
     * |suggestionIndex|.
     */
    /* package */ String getPaymentMethodSuggestionLabel(final int suggestionIndex) {
        Assert.assertTrue(suggestionIndex < getNumberOfPaymentApps());

        return ThreadUtils.runOnUiThreadBlocking(
                () ->
                        ((OptionSection) mUI.getPaymentMethodSectionForTest())
                                .getOptionLabelsForTest(suggestionIndex)
                                .getText()
                                .toString());
    }

    /** Returns the number of contact detail suggestions. */
    /* package */ int getNumberOfContactDetailSuggestions() {
        return ThreadUtils.runOnUiThreadBlocking(
                () ->
                        ((OptionSection) mUI.getContactDetailsSectionForTest())
                                .getNumberOfOptionLabelsForTest());
    }

    /**
     * Returns the label corresponding to the shipping address suggestion at the specified
     * |suggestionIndex|.
     */
    /* package */ String getShippingAddressSuggestionLabel(final int suggestionIndex) {
        Assert.assertTrue(suggestionIndex < getNumberOfShippingAddressSuggestions());

        return ThreadUtils.runOnUiThreadBlocking(
                () ->
                        mUI.getShippingAddressSectionForTest()
                                .getOptionLabelsForTest(suggestionIndex)
                                .getText()
                                .toString());
    }

    /* package */ String getShippingAddressSummary() {
        return ThreadUtils.runOnUiThreadBlocking(
                () ->
                        mUI.getShippingAddressSectionForTest()
                                .getLeftSummaryLabelForTest()
                                .getText()
                                .toString());
    }

    /* package */ String getShippingOptionSummary() {
        return ThreadUtils.runOnUiThreadBlocking(
                () ->
                        mUI.getShippingOptionSectionForTest()
                                .getLeftSummaryLabelForTest()
                                .getText()
                                .toString());
    }

    /* package */ String getShippingOptionCostSummaryOnBottomSheet() {
        return ThreadUtils.runOnUiThreadBlocking(
                () ->
                        mUI.getShippingOptionSectionForTest()
                                .getRightSummaryLabelForTest()
                                .getText()
                                .toString());
    }

    /* package */ String getShippingAddressWarningLabel() {
        return ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    View view =
                            mUI.getShippingAddressSectionForTest()
                                    .findViewById(R.id.payments_warning_label);
                    return view != null && view instanceof TextView
                            ? ((TextView) view).getText().toString()
                            : null;
                });
    }

    /* package */ String getShippingAddressDescriptionLabel() {
        return ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    View view =
                            mUI.getShippingAddressSectionForTest()
                                    .findViewById(R.id.payments_description_label);
                    return view != null && view instanceof TextView
                            ? ((TextView) view).getText().toString()
                            : null;
                });
    }

    /**
     * Clicks on the label corresponding to the shipping address suggestion at the specified
     * |suggestionIndex|.
     */
    /* package */ void clickOnShippingAddressSuggestionOptionAndWait(
            final int suggestionIndex, CallbackHelper helper) throws TimeoutException {
        Assert.assertTrue(suggestionIndex < getNumberOfShippingAddressSuggestions());

        int callCount = helper.getCallCount();
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    ((OptionSection) mUI.getShippingAddressSectionForTest())
                            .getOptionLabelsForTest(suggestionIndex)
                            .performClick();
                });
        helper.waitForCallback(callCount);
    }

    /**
     * Clicks on the label corresponding to the payment method suggestion at the specified
     * |suggestionIndex|.
     */
    /* package */ void clickOnPaymentMethodSuggestionOptionAndWait(
            final int suggestionIndex, CallbackHelper helper) throws TimeoutException {
        Assert.assertTrue(suggestionIndex < getNumberOfPaymentApps());

        int callCount = helper.getCallCount();
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    ((OptionSection) mUI.getPaymentMethodSectionForTest())
                            .getOptionLabelsForTest(suggestionIndex)
                            .performClick();
                });
        helper.waitForCallback(callCount);
    }

    /**
     * Clicks on the label corresponding to the contact info suggestion at the specified
     * |suggestionIndex|.
     */
    /* package */ void clickOnContactInfoSuggestionOptionAndWait(
            final int suggestionIndex, CallbackHelper helper) throws TimeoutException {
        Assert.assertTrue(suggestionIndex < getNumberOfContactDetailSuggestions());

        int callCount = helper.getCallCount();
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    ((OptionSection) mUI.getContactDetailsSectionForTest())
                            .getOptionLabelsForTest(suggestionIndex)
                            .performClick();
                });
        helper.waitForCallback(callCount);
    }

    /**
     * Clicks on the edit icon corresponding to the payment method suggestion at the specified
     * |suggestionIndex|.
     */
    /* package */ void clickOnPaymentMethodSuggestionEditIconAndWait(
            final int suggestionIndex, CallbackHelper helper) throws TimeoutException {
        Assert.assertTrue(suggestionIndex < getNumberOfPaymentApps());

        int callCount = helper.getCallCount();
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    ((OptionSection) mUI.getPaymentMethodSectionForTest())
                            .getOptionRowAtIndex(suggestionIndex)
                            .getEditIconForTest()
                            .performClick();
                });
        helper.waitForCallback(callCount);
    }

    /** Returns the summary text of the shipping address section. */
    /* package */ String getShippingAddressSummaryLabel() {
        return getShippingAddressSummary();
    }

    /** Returns the summary text of the shipping option section. */
    /* package */ String getShippingOptionSummaryLabel() {
        return getShippingOptionSummary();
    }

    /** Returns the cost text of the shipping option section on the bottom sheet. */
    /* package */ String getShippingOptionCostSummaryLabelOnBottomSheet() {
        return getShippingOptionCostSummaryOnBottomSheet();
    }

    /** Returns the number of shipping address suggestions. */
    /* package */ int getNumberOfShippingAddressSuggestions() {
        return ThreadUtils.runOnUiThreadBlocking(
                () ->
                        ((OptionSection) mUI.getShippingAddressSectionForTest())
                                .getNumberOfOptionLabelsForTest());
    }

    /** Returns the {@link OptionRow} at the given index for the shipping address section. */
    /* package */ OptionRow getShippingAddressOptionRowAtIndex(final int index) {
        return ThreadUtils.runOnUiThreadBlocking(
                () ->
                        ((OptionSection) mUI.getShippingAddressSectionForTest())
                                .getOptionRowAtIndex(index));
    }

    /** Returns the error message visible to the user in the credit card unmask prompt. */
    /* package */ String getUnmaskPromptErrorMessage() {
        return mCardUnmaskPrompt.getErrorMessage();
    }

    /** Selects the spinner value in the editor UI. */
    /* package */ void setSpinnerSelectionInEditorAndWait(
            final int selection, CallbackHelper helper) throws TimeoutException {
        int callCount = helper.getCallCount();
        ThreadUtils.runOnUiThreadBlocking(
                () ->
                        ((Spinner) mUI.getEditorDialog().findViewById(R.id.spinner))
                                .setSelection(selection));
        helper.waitForCallback(callCount);
    }

    /** Directly sets the text in the editor UI. */
    /* package */ void setTextInEditorAndWait(final String[] values, CallbackHelper helper)
            throws TimeoutException {
        int callCount = helper.getCallCount();
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    List<EditText> fields = mUI.getEditorDialog().getEditableTextFieldsForTest();
                    for (int i = 0; i < values.length; i++) {
                        fields.get(i).requestFocus();
                        fields.get(i).setText(values[i]);
                    }
                });
        helper.waitForCallback(callCount);
    }

    /** Directly sets the text in the card unmask UI. */
    /* package */ void setTextInCardUnmaskDialogAndWait(
            final int resourceId, final String input, CallbackHelper helper)
            throws TimeoutException {
        int callCount = helper.getCallCount();
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    EditText editText =
                            mCardUnmaskPrompt
                                    .getDialogForTest()
                                    .get(ModalDialogProperties.CUSTOM_VIEW)
                                    .findViewById(resourceId);
                    editText.setText(input);
                    editText.getOnFocusChangeListener().onFocusChange(null, false);
                });
        helper.waitForCallback(callCount);
    }

    /** Directly sets the text in the expired card unmask UI. */
    /* package */ void setTextInExpiredCardUnmaskDialogAndWait(
            final int[] resourceIds, final String[] values, CallbackHelper helper)
            throws TimeoutException {
        assert resourceIds.length == values.length;
        int callCount = helper.getCallCount();
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    for (int i = 0; i < resourceIds.length; ++i) {
                        EditText editText =
                                mCardUnmaskPrompt
                                        .getDialogForTest()
                                        .get(ModalDialogProperties.CUSTOM_VIEW)
                                        .findViewById(resourceIds[i]);
                        editText.setText(values[i]);
                        editText.getOnFocusChangeListener().onFocusChange(null, false);
                    }
                });
        helper.waitForCallback(callCount);
    }

    /** Focues a view and hits the "submit" button on the software keyboard. */
    /* package */ void hitSoftwareKeyboardSubmitButtonAndWait(
            final int resourceId, CallbackHelper helper) throws TimeoutException {
        int callCount = helper.getCallCount();
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    EditText editText =
                            mCardUnmaskPrompt
                                    .getDialogForTest()
                                    .get(ModalDialogProperties.CUSTOM_VIEW)
                                    .findViewById(resourceId);
                    editText.requestFocus();
                    editText.onEditorAction(EditorInfo.IME_ACTION_DONE);
                });
        helper.waitForCallback(callCount);
    }

    /** Verifies the contents of the test webpage. */
    /* package */ void expectResultContains(final String[] contents) {
        CriteriaHelper.pollInstrumentationThread(
                () -> {
                    try {
                        String result = DOMUtils.getNodeContents(mWebContentsRef.get(), "result");
                        Criteria.checkThat(
                                "Cannot find 'result' node on test page",
                                result,
                                Matchers.notNullValue());
                        for (int i = 0; i < contents.length; i++) {
                            Criteria.checkThat(
                                    "Result '" + result + "' should contain '" + contents[i] + "'",
                                    result,
                                    Matchers.containsString(contents[i]));
                        }
                    } catch (TimeoutException e2) {
                        throw new CriteriaNotSatisfiedException(e2);
                    }
                });
    }

    /** Will fail if the OptionRow at |index| is not selected in Contact Details. */
    /* package */ void expectContactDetailsRowIsSelected(final int index) {
        CriteriaHelper.pollInstrumentationThread(
                () -> {
                    boolean isSelected =
                            ((OptionSection) mUI.getContactDetailsSectionForTest())
                                    .getOptionRowAtIndex(index)
                                    .isChecked();
                    Criteria.checkThat(
                            "Contact Details row at " + index + " was not selected.",
                            isSelected,
                            Matchers.is(true));
                });
    }

    /** Will fail if the OptionRow at |index| is not selected in Shipping Address section. */
    /* package */ void expectShippingAddressRowIsSelected(final int index) {
        CriteriaHelper.pollInstrumentationThread(
                () -> {
                    boolean isSelected =
                            ((OptionSection) mUI.getShippingAddressSectionForTest())
                                    .getOptionRowAtIndex(index)
                                    .isChecked();
                    Criteria.checkThat(
                            "Shipping Address row at " + index + " was not selected.",
                            isSelected,
                            Matchers.is(true));
                });
    }

    /** Will fail if the OptionRow at |index| is not selected in PaymentMethod section. */
    /* package */ void expectPaymentMethodRowIsSelected(final int index) {
        CriteriaHelper.pollInstrumentationThread(
                () -> {
                    boolean isSelected =
                            ((OptionSection) mUI.getPaymentMethodSectionForTest())
                                    .getOptionRowAtIndex(index)
                                    .isChecked();
                    Criteria.checkThat(
                            "Payment Method row at " + index + " was not selected.",
                            isSelected,
                            Matchers.is(true));
                });
    }

    /* package */ View getPaymentRequestView() {
        return ThreadUtils.runOnUiThreadBlocking(
                () -> mUI.getDialogForTest().findViewById(R.id.payment_request));
    }

    /* package */ View getCardUnmaskView() throws Throwable {
        return ThreadUtils.runOnUiThreadBlocking(
                () ->
                        mCardUnmaskPrompt
                                .getDialogForTest()
                                .get(ModalDialogProperties.CUSTOM_VIEW)
                                .findViewById(R.id.autofill_card_unmask_prompt));
    }

    /* package */ View getEditorDialogView() throws Throwable {
        return ThreadUtils.runOnUiThreadBlocking(
                () -> mUI.getEditorDialog().findViewById(R.id.editor_container));
    }

    /* package */ void setAutoAdvanceInputProtectorClock(boolean autoAdvanceInputProtectorClock) {
        mAutoAdvanceInputProtectorClock = autoAdvanceInputProtectorClock;
    }

    /* package */ void advanceInputProtectorClock() {
        mClock.advanceCurrentTimeMillis(SAFE_INPUT_DELAY);
    }

    @Override
    public void onPaymentRequestUIShow(PaymentRequestUI ui) {
        ThreadUtils.assertOnUiThread();
        mUI = ui;
        mInputProtector.markShowTime();
        mUI.setInputProtectorForTest(mInputProtector);
        // By default, we advance the clock immediately as most tests just wait for ReadyForInput.
        if (mAutoAdvanceInputProtectorClock) {
            advanceInputProtectorClock();
        }
        mShowCalled.notifyCalled(ui);
    }

    @Override
    public void onPaymentRequestReadyForInput(PaymentRequestUI ui) {
        ThreadUtils.assertOnUiThread();
        mReadyForInput.notifyCalled(ui);
    }

    @Override
    public void onEditorReadyToEdit() {
        ThreadUtils.assertOnUiThread();
        mReadyToEdit.notifyCalled();
    }

    @Override
    public void onEditorValidationError() {
        ThreadUtils.assertOnUiThread();
        mEditorValidationError.notifyCalled();
    }

    @Override
    public void onEditorTextUpdate() {
        ThreadUtils.assertOnUiThread();
        mEditorTextUpdate.notifyCalled();
    }

    @Override
    public void onEditorConfirmationDialogShown() {
        // Not used.
    }

    @Override
    public void onPaymentRequestReadyToPay(PaymentRequestUI ui) {
        ThreadUtils.assertOnUiThread();
        mReadyToPay.notifyCalled(ui);
    }

    @Override
    public void onPaymentRequestSelectionChecked(PaymentRequestUI ui) {
        ThreadUtils.assertOnUiThread();
        mSelectionChecked.notifyCalled(ui);
    }

    @Override
    public void onPaymentRequestResultReady(PaymentRequestUI ui) {
        ThreadUtils.assertOnUiThread();
        mResultReady.notifyCalled(ui);
    }

    @Override
    public void onEditorDismiss() {
        ThreadUtils.assertOnUiThread();
        mDismissed.notifyCalled();
    }

    @Override
    public void onCreatedChromePaymentRequestDelegateImpl(
            ChromePaymentRequestDelegateImpl delegateImpl) {
        ThreadUtils.assertOnUiThread();
        mChromePaymentRequestDelegateImpl = delegateImpl;
    }

    @Override
    public void onPaymentRequestServiceUnableToAbort() {
        ThreadUtils.assertOnUiThread();
        mUnableToAbort.notifyCalled();
    }

    @Override
    public void onPaymentRequestServiceBillingAddressChangeProcessed() {
        ThreadUtils.assertOnUiThread();
        mBillingAddressChangeProcessed.notifyCalled();
    }

    @Override
    public void onPaymentRequestServiceExpirationMonthChange() {
        ThreadUtils.assertOnUiThread();
        mExpirationMonthChange.notifyCalled();
    }

    @Override
    public void onPaymentRequestServiceShowFailed() {
        ThreadUtils.assertOnUiThread();
        mShowFailed.notifyCalled();
    }

    @Override
    public void onPaymentRequestServiceCanMakePaymentQueryResponded() {
        ThreadUtils.assertOnUiThread();
        mCanMakePaymentQueryResponded.notifyCalled();
    }

    @Override
    public void onPaymentRequestServiceHasEnrolledInstrumentQueryResponded() {
        ThreadUtils.assertOnUiThread();
        mHasEnrolledInstrumentQueryResponded.notifyCalled();
    }

    @Override
    public void onCardUnmaskPromptReadyForInput(CardUnmaskPrompt prompt) {
        ThreadUtils.assertOnUiThread();
        mReadyForUnmaskInput.notifyCalled(prompt);
        mCardUnmaskPrompt = prompt;
    }

    @Override
    public void onCardUnmaskPromptReadyToUnmask(CardUnmaskPrompt prompt) {
        ThreadUtils.assertOnUiThread();
        mReadyToUnmask.notifyCalled(prompt);
    }

    @Override
    public void onCardUnmaskPromptValidationDone(CardUnmaskPrompt prompt) {
        ThreadUtils.assertOnUiThread();
        mUnmaskValidationDone.notifyCalled(prompt);
    }

    @Override
    public void onCardUnmaskPromptSubmitRejected(CardUnmaskPrompt prompt) {
        ThreadUtils.assertOnUiThread();
        mSubmitRejected.notifyCalled(prompt);
    }

    @Override
    public void onPaymentResponseReady() {
        ThreadUtils.assertOnUiThread();
        mPaymentResponseReady.notifyCalled();
    }

    @Override
    public void onCompletedHandled() {
        ThreadUtils.assertOnUiThread();
        mCompleteHandled.notifyCalled();
    }

    @Override
    public void onRendererClosedMojoConnection() {
        ThreadUtils.assertOnUiThread();
        mRendererClosedMojoConnection.notifyCalled();
    }

    /** Listens for UI notifications. */
    static class PaymentsCallbackHelper<T> extends CallbackHelper {
        private T mTarget;

        /**
         * Returns the UI that is ready for input.
         *
         * @return The UI that is ready for input.
         */
        /* package */ T getTarget() {
            return mTarget;
        }

        /**
         * Called when the UI is ready for input.
         *
         * @param target The UI that is ready for input.
         */
        /* package */ void notifyCalled(T target) {
            ThreadUtils.assertOnUiThread();
            mTarget = target;
            notifyCalled();
        }
    }

    /**
     * Adds a payment app factory for testing.
     *
     * @param appPresence Whether the factory has apps.
     * @param factorySpeed How quick the factory creates apps.
     * @return The test factory. Can be ignored.
     */
    /* package */ TestFactory addPaymentAppFactory(
            @AppPresence int appPresence, @FactorySpeed int factorySpeed) {
        return addPaymentAppFactory("https://bobpay.test", appPresence, factorySpeed);
    }

    /**
     * Adds a payment app factory for testing.
     *
     * @param methodName The name of the payment method used in the payment app.
     * @param appPresence Whether the factory has apps.
     * @param factorySpeed How quick the factory creates apps.
     * @return The test factory. Can be ignored.
     */
    /* package */ TestFactory addPaymentAppFactory(
            String methodName, @AppPresence int appPresence, @FactorySpeed int factorySpeed) {
        return addPaymentAppFactory(methodName, appPresence, factorySpeed, AppSpeed.FAST_APP);
    }

    /**
     * Adds a payment app factory for testing.
     *
     * @param methodName The name of the payment method used in the payment app.
     * @param appPresence Whether the factory has apps.
     * @param factorySpeed How quick the factory creates apps.
     * @param appSpeed How quick the app responds to "invoke".
     * @return The test factory. Can be ignored.
     */
    /* package */ TestFactory addPaymentAppFactory(
            String appMethodName,
            int appPresence,
            @FactorySpeed int factorySpeed,
            @AppSpeed int appSpeed) {
        TestFactory factory = new TestFactory(appMethodName, appPresence, factorySpeed, appSpeed);
        PaymentAppService.getInstance().addFactory(factory);
        return factory;
    }

    /** A payment app factory implementation for test. */
    /* package */ static final class TestFactory implements PaymentAppFactoryInterface {
        private final String mAppMethodName;
        private final @AppPresence int mAppPresence;
        private final @FactorySpeed int mFactorySpeed;
        private final @AppSpeed int mAppSpeed;
        private PaymentAppFactoryDelegate mDelegate;

        private TestFactory(
                String appMethodName,
                @AppPresence int appPresence,
                @FactorySpeed int factorySpeed,
                @AppSpeed int appSpeed) {
            mAppMethodName = appMethodName;
            mAppPresence = appPresence;
            mFactorySpeed = factorySpeed;
            mAppSpeed = appSpeed;
        }

        @Override
        public void create(PaymentAppFactoryDelegate delegate) {
            Runnable createApp =
                    () -> {
                        if (delegate.getParams().hasClosed()) return;
                        boolean canMakePayment =
                                delegate.getParams().getMethodData().containsKey(mAppMethodName);
                        delegate.onCanMakePaymentCalculated(canMakePayment);
                        if (canMakePayment && mAppPresence == AppPresence.HAVE_APPS) {
                            delegate.onPaymentAppCreated(new TestPay(mAppMethodName, mAppSpeed));
                        }
                        delegate.onDoneCreatingPaymentApps(this);
                    };
            if (mFactorySpeed == FactorySpeed.FAST_FACTORY) {
                createApp.run();
            } else {
                new Handler().postDelayed(createApp, 100);
            }
            mDelegate = delegate;
        }

        /* package */ PaymentAppFactoryDelegate getDelegateForTest() {
            return mDelegate;
        }
    }

    /** A payment app implementation for test. */
    /* package */ static final class TestPay extends PaymentApp {
        private final String mDefaultMethodName;
        private final @AppSpeed int mAppSpeed;

        TestPay(String defaultMethodName, @AppSpeed int appSpeed) {
            super(
                    /* id= */ UUID.randomUUID().toString(),
                    /* label= */ defaultMethodName,
                    /* sublabel= */ null,
                    /* icon= */ null);
            mDefaultMethodName = defaultMethodName;
            mAppSpeed = appSpeed;
        }

        @Override
        public Set<String> getInstrumentMethodNames() {
            Set<String> result = new HashSet<>();
            result.add(mDefaultMethodName);
            return result;
        }

        @Override
        public void invokePaymentApp(
                String id,
                String merchantName,
                String origin,
                String iframeOrigin,
                byte[][] certificateChain,
                Map<String, PaymentMethodData> methodData,
                PaymentItem total,
                List<PaymentItem> displayItems,
                Map<String, PaymentDetailsModifier> modifiers,
                PaymentOptions paymentOptions,
                List<PaymentShippingOption> shippingOptions,
                InstrumentDetailsCallback detailsCallback) {
            Runnable respond =
                    () -> {
                        detailsCallback.onInstrumentDetailsReady(
                                mDefaultMethodName,
                                "{\"transaction\": 1337, \"total\": \""
                                        + total.amount.value
                                        + "\"}",
                                new PayerData());
                    };
            if (mAppSpeed == AppSpeed.FAST_APP) {
                respond.run();
            } else {
                new Handler().postDelayed(respond, 100);
            }
        }

        @Override
        public void dismissInstrument() {}
    }

    public void startMainActivity() {
        assert mDelayStartActivity;
        startMainActivityWithURL(mTestFilePath);
    }

    @Override
    protected void before() throws Throwable {
        super.before();
        if (!mDelayStartActivity) {
            startMainActivityWithURL(mTestFilePath);
            setObserversAndWaitForInitialPageLoad();
        }
    }
}