chromium/chrome/test/android/javatests/src/org/chromium/chrome/test/transit/page/PageStation.java

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

package org.chromium.chrome.test.transit.page;

import static androidx.test.espresso.action.ViewActions.longClick;
import static androidx.test.espresso.matcher.ViewMatchers.withId;

import static org.chromium.base.test.transit.ViewSpec.viewSpec;

import org.chromium.base.ThreadUtils;
import org.chromium.base.supplier.Supplier;
import org.chromium.base.test.transit.ActivityElement;
import org.chromium.base.test.transit.CallbackCondition;
import org.chromium.base.test.transit.Condition;
import org.chromium.base.test.transit.ConditionStatus;
import org.chromium.base.test.transit.ConditionStatusWithResult;
import org.chromium.base.test.transit.ConditionWithResult;
import org.chromium.base.test.transit.Elements;
import org.chromium.base.test.transit.Facility;
import org.chromium.base.test.transit.Station;
import org.chromium.base.test.transit.Transition;
import org.chromium.base.test.transit.ViewElement;
import org.chromium.base.test.transit.ViewSpec;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.ChromeTabbedActivity;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tabmodel.TabModel;
import org.chromium.chrome.browser.tabmodel.TabModelObserver;
import org.chromium.chrome.test.transit.hub.IncognitoTabSwitcherStation;
import org.chromium.chrome.test.transit.hub.RegularTabSwitcherStation;
import org.chromium.content_public.browser.LoadUrlParams;
import org.chromium.ui.base.PageTransition;

import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;

/**
 * The screen that shows a web or native page with the toolbar within a tab.
 *
 * <p>Contains extra configurable Conditions such as waiting for a tab to be created, selected, have
 * the expected title, etc.
 */
public class PageStation extends Station {
    /**
     * Builder for all PageStation subclasses.
     *
     * @param <T> the subclass of PageStation to build.
     */
    public static class Builder<T extends PageStation> {
        private final Function<Builder<T>, T> mFactoryMethod;
        private boolean mIsEntryPoint;
        private Boolean mIncognito;
        private Integer mNumTabsBeingOpened;
        private Integer mNumTabsBeingSelected;
        private Tab mTabAlreadySelected;
        private String mExpectedUrlSubstring;
        private String mExpectedTitle;
        private List<Facility<T>> mFacilities;

        public Builder(Function<Builder<T>, T> factoryMethod) {
            mFactoryMethod = factoryMethod;
        }

        public Builder<T> withIncognito(boolean incognito) {
            mIncognito = incognito;
            return this;
        }

        public Builder<T> withIsOpeningTabs(int numTabsBeingOpened) {
            assert numTabsBeingOpened >= 0;
            mNumTabsBeingOpened = numTabsBeingOpened;
            return this;
        }

        public Builder<T> withTabAlreadySelected(Tab currentTab) {
            mTabAlreadySelected = currentTab;
            mNumTabsBeingSelected = 0;
            return this;
        }

        public Builder<T> withIsSelectingTabs(int numTabsBeingSelected) {
            assert numTabsBeingSelected > 0
                    : "Use withIsSelectingTab() if the PageStation is still in the current tab";
            mNumTabsBeingSelected = numTabsBeingSelected;
            // Commonly already set via initFrom().
            mTabAlreadySelected = null;
            return this;
        }

        public Builder<T> withEntryPoint() {
            mNumTabsBeingOpened = 0;
            mNumTabsBeingSelected = 0;
            mIsEntryPoint = true;
            return this;
        }

        public Builder<T> withExpectedUrlSubstring(String value) {
            mExpectedUrlSubstring = value;
            return this;
        }

        public Builder<T> withExpectedTitle(String title) {
            mExpectedTitle = title;
            return this;
        }

        public Builder<T> withFacility(Facility<T> facility) {
            if (mFacilities == null) {
                mFacilities = new ArrayList<>();
            }
            mFacilities.add(facility);
            return this;
        }

        public Builder<T> initFrom(PageStation previousStation) {
            if (mIncognito == null) {
                mIncognito = previousStation.mIncognito;
            }
            if (mNumTabsBeingOpened == null) {
                mNumTabsBeingOpened = 0;
            }
            if (mNumTabsBeingSelected == null) {
                mNumTabsBeingSelected = 0;
            }
            if (mTabAlreadySelected == null && mNumTabsBeingSelected == 0) {
                mTabAlreadySelected = previousStation.getLoadedTab();
            }
            // Cannot copy over facilities because we have no way to clone them. It's also not
            // obvious that we should...
            return this;
        }

        public T build() {
            return mFactoryMethod.apply(this);
        }
    }

    protected final boolean mIncognito;
    protected final boolean mIsEntryPoint;
    protected final int mNumTabsBeingOpened;
    protected final int mNumTabsBeingSelected;
    protected final Tab mTabAlreadySelected;
    protected final String mExpectedUrlSubstring;
    protected final String mExpectedTitle;

    public static final ViewSpec HOME_BUTTON = viewSpec(withId(R.id.home_button));
    public static final ViewSpec TAB_SWITCHER_BUTTON = viewSpec(withId(R.id.tab_switcher_button));
    public static final ViewSpec MENU_BUTTON = viewSpec(withId(R.id.menu_button));

    protected ActivityElement<ChromeTabbedActivity> mActivityElement;
    protected Supplier<Tab> mActivityTabSupplier;
    protected Supplier<Tab> mSelectedTabSupplier;
    protected Supplier<Tab> mPageLoadedSupplier;

    /** Use the PageStation's subclass |newBuilder()|. */
    protected <T extends PageStation> PageStation(Builder<T> builder) {
        // incognito is optional and defaults to false
        mIncognito = builder.mIncognito == null ? false : builder.mIncognito;

        // isEntryPoint is optional and defaults to false
        mIsEntryPoint = builder.mIsEntryPoint;

        // isOpeningTab is required
        assert builder.mNumTabsBeingOpened != null;
        mNumTabsBeingOpened = builder.mNumTabsBeingOpened;

        // mNumTabsBeingSelected is required
        assert builder.mNumTabsBeingSelected != null;
        mNumTabsBeingSelected = builder.mNumTabsBeingSelected;

        // Pages must have an already selected tab, or be selecting a tab.
        mTabAlreadySelected = builder.mTabAlreadySelected;
        assert mIsEntryPoint || (mTabAlreadySelected != null) != (mNumTabsBeingSelected != 0)
                : String.format(
                        "mTabAlreadySelected=%s mNumTabsBeingSelected=%s",
                        mTabAlreadySelected, mNumTabsBeingSelected);

        // URL substring is optional.
        mExpectedUrlSubstring = builder.mExpectedUrlSubstring;

        // title is optional
        mExpectedTitle = builder.mExpectedTitle;

        if (builder.mFacilities != null) {
            for (Facility<T> facility : builder.mFacilities) {
                addInitialFacility(facility);
            }
        }
    }

    @Override
    public void declareElements(Elements.Builder elements) {
        mActivityElement = elements.declareActivity(ChromeTabbedActivity.class);

        // TODO(crbug.com/41497463): These should be scoped, but for now they need to be unscoped
        // since they unintentionally still exist in the non-Hub tab switcher. They are mostly
        // occluded by the tab switcher toolbar, but at least the tab_switcher_button is still
        // visible.
        elements.declareView(HOME_BUTTON, ViewElement.unscopedOption());
        elements.declareView(TAB_SWITCHER_BUTTON, ViewElement.unscopedOption());
        elements.declareView(MENU_BUTTON, ViewElement.unscopedOption());

        if (mNumTabsBeingOpened > 0) {
            elements.declareEnterCondition(
                    new TabAddedCondition(mNumTabsBeingOpened, mActivityElement));
        }

        if (mIsEntryPoint) {
            // In entry points we just match the first ActivityTab we see, instead of waiting for
            // callbacks.
            mActivityTabSupplier =
                    elements.declareEnterCondition(new AnyActivityTabCondition(mActivityElement));
        } else {
            if (mNumTabsBeingSelected > 0) {
                // The last tab of N opened is the Tab that mSelectedTabSupplier will supply.
                mSelectedTabSupplier =
                        elements.declareEnterCondition(
                                new TabSelectedCondition(mNumTabsBeingSelected, mActivityElement));
            } else {
                // The Tab already created and provided to the constructor is the one that is
                // expected to be the activityTab.
                mSelectedTabSupplier = () -> mTabAlreadySelected;
            }
            // Only returns the tab when it is the activityTab.
            mActivityTabSupplier =
                    elements.declareEnterCondition(
                            new CorrectActivityTabCondition(
                                    mActivityElement, mSelectedTabSupplier));
        }
        mPageLoadedSupplier =
                elements.declareEnterCondition(
                        new PageLoadedCondition(mActivityTabSupplier, mIncognito));

        elements.declareEnterCondition(new PageInteractableOrHiddenCondition(mPageLoadedSupplier));

        if (mExpectedTitle != null) {
            elements.declareEnterCondition(
                    new PageTitleCondition(mExpectedTitle, mPageLoadedSupplier));
        }
        if (mExpectedUrlSubstring != null) {
            elements.declareEnterCondition(
                    new PageUrlContainsCondition(mExpectedUrlSubstring, mPageLoadedSupplier));
        }
    }

    public boolean isIncognito() {
        return mIncognito;
    }

    /** Condition to check the page title. */
    public static class PageTitleCondition extends Condition {
        private final String mExpectedTitle;
        private final Supplier<Tab> mLoadedTabSupplier;

        public PageTitleCondition(String expectedTitle, Supplier<Tab> loadedTabSupplier) {
            super(/* isRunOnUiThread= */ true);
            mExpectedTitle = expectedTitle;
            mLoadedTabSupplier = dependOnSupplier(loadedTabSupplier, "LoadedTab");
        }

        @Override
        protected ConditionStatus checkWithSuppliers() throws Exception {
            String title = mLoadedTabSupplier.get().getTitle();
            return whether(mExpectedTitle.equals(title), "ActivityTab title: \"%s\"", title);
        }

        @Override
        public String buildDescription() {
            return String.format("Title of activity tab is \"%s\"", mExpectedTitle);
        }
    }

    /** Condition to check the page url contains a certain substring. */
    public static class PageUrlContainsCondition extends Condition {
        private final String mExpectedUrlPiece;
        private final Supplier<Tab> mLoadedTabSupplier;

        public PageUrlContainsCondition(String expectedUrl, Supplier<Tab> loadedTabSupplier) {
            super(/* isRunOnUiThread= */ true);
            mExpectedUrlPiece = expectedUrl;
            mLoadedTabSupplier = dependOnSupplier(loadedTabSupplier, "LoadedTab");
        }

        @Override
        protected ConditionStatus checkWithSuppliers() throws Exception {
            String url = mLoadedTabSupplier.get().getUrl().getSpec();
            return whether(url.contains(mExpectedUrlPiece), "ActivityTab url: \"%s\"", url);
        }

        @Override
        public String buildDescription() {
            return String.format("URL of activity tab contains \"%s\"", mExpectedUrlPiece);
        }
    }

    /** Long presses the tab switcher button to open the action menu. */
    public TabSwitcherActionMenuFacility openTabSwitcherActionMenu() {
        recheckActiveConditions();
        return enterFacilitySync(
                new TabSwitcherActionMenuFacility(),
                () -> TAB_SWITCHER_BUTTON.perform(longClick()));
    }

    /** Opens the app menu by pressing the toolbar "..." button */
    public PageAppMenuFacility<PageStation> openGenericAppMenu() {
        recheckActiveConditions();

        return enterFacilitySync(new PageAppMenuFacility<PageStation>(), MENU_BUTTON::click);
    }

    /** Opens the tab switcher by pressing the toolbar tab switcher button. */
    public RegularTabSwitcherStation openRegularTabSwitcher() {
        assert !mIncognito;
        return travelToSync(
                RegularTabSwitcherStation.from(getActivity().getTabModelSelector()),
                TAB_SWITCHER_BUTTON::click);
    }

    /** Opens the incognito tab switcher by pressing the toolbar tab switcher button. */
    public IncognitoTabSwitcherStation openIncognitoTabSwitcher() {
        assert mIncognito;
        return travelToSync(
                IncognitoTabSwitcherStation.from(getActivity().getTabModelSelector()),
                TAB_SWITCHER_BUTTON::click);
    }

    /** Loads a |url| in the same tab and waits to transition. */
    public <T extends PageStation> T loadPageProgrammatically(String url, Builder<T> builder) {
        builder.initFrom(this);
        if (builder.mExpectedUrlSubstring == null) {
            builder.mExpectedUrlSubstring = url;
        }

        T destination = builder.build();
        Runnable r =
                () -> {
                    @PageTransition
                    int transitionType = PageTransition.TYPED | PageTransition.FROM_ADDRESS_BAR;
                    getActivity().getActivityTab().loadUrl(new LoadUrlParams(url, transitionType));
                };
        // TODO(b/341978208): Wait for a page loaded callback.
        Transition.TransitionOptions options =
                Transition.newOptions().withTimeout(10000).withPossiblyAlreadyFulfilled().build();
        return travelToSync(destination, options, () -> ThreadUtils.runOnUiThread(r));
    }

    public WebPageStation loadAboutBlank() {
        return loadPageProgrammatically("about:blank", WebPageStation.newBuilder());
    }

    /**
     * Returns the {@link ChromeTabbedActivity} matched to the ActivityCondition.
     *
     * <p>The element is only guaranteed to exist as long as the station is ACTIVE or in transition
     * triggers when it is already TRANSITIONING_FROM.
     */
    public ChromeTabbedActivity getActivity() {
        assertSuppliersCanBeUsed();
        return mActivityElement.get();
    }

    public Tab getLoadedTab() {
        assertSuppliersCanBeUsed();
        return mPageLoadedSupplier.get();
    }

    private class TabAddedCondition extends CallbackCondition implements TabModelObserver {
        private TabModel mTabModel;
        private Supplier<ChromeTabbedActivity> mActivitySupplier;

        protected TabAddedCondition(
                int numTabsBeingOpened, Supplier<ChromeTabbedActivity> activitySupplier) {
            super("didAddTab", numTabsBeingOpened);
            mActivitySupplier = dependOnSupplier(activitySupplier, "ChromeTabbedActivity");
        }

        @Override
        public void didAddTab(Tab tab, int type, int creationState, boolean markedForSelection) {
            notifyCalled();
        }

        @Override
        public void onStartMonitoring() {
            super.onStartMonitoring();
            ThreadUtils.runOnUiThreadBlocking(
                    () -> {
                        mTabModel =
                                mActivitySupplier
                                        .get()
                                        .getTabModelSelector()
                                        .getModel(isIncognito());
                        mTabModel.addObserver(this);
                    });
        }

        @Override
        public void onStopMonitoring() {
            ThreadUtils.runOnUiThreadBlocking(
                    () -> {
                        mTabModel.removeObserver(this);
                    });
        }
    }

    private class TabSelectedCondition extends CallbackCondition
            implements TabModelObserver, Supplier<Tab> {
        private final List<Tab> mTabsSelected = new ArrayList<>();
        private TabModel mTabModel;
        private Supplier<ChromeTabbedActivity> mActivitySupplier;

        private TabSelectedCondition(
                int numTabsBeingSelected, Supplier<ChromeTabbedActivity> activitySupplier) {
            super("didSelectTab", numTabsBeingSelected);
            mActivitySupplier = dependOnSupplier(activitySupplier, "ChromeTabbedActivity");
        }

        @Override
        public void didSelectTab(Tab tab, int type, int lastId) {
            if (mTabsSelected.contains(tab)) {
                // We get multiple (2-3 depending on the case) didSelectTab when selecting a Tab, so
                // filter out redundant callbacks to make sure we wait for different Tabs.
                return;
            }
            mTabsSelected.add(tab);
            notifyCalled();
        }

        @Override
        public void onStartMonitoring() {
            super.onStartMonitoring();
            ThreadUtils.runOnUiThreadBlocking(
                    () -> {
                        mTabModel =
                                mActivitySupplier
                                        .get()
                                        .getTabModelSelector()
                                        .getModel(isIncognito());
                        mTabModel.addObserver(this);
                    });
        }

        @Override
        public void onStopMonitoring() {
            super.onStopMonitoring();
            ThreadUtils.runOnUiThreadBlocking(
                    () -> {
                        mTabModel.removeObserver(this);
                    });
        }

        @Override
        public Tab get() {
            if (mTabsSelected.isEmpty()) {
                return null;
            }
            return mTabsSelected.get(mTabsSelected.size() - 1);
        }

        @Override
        public boolean hasValue() {
            return !mTabsSelected.isEmpty();
        }
    }

    private static class CorrectActivityTabCondition extends ConditionWithResult<Tab> {

        private final Supplier<ChromeTabbedActivity> mActivitySupplier;
        private final Supplier<Tab> mExpectedTab;

        private CorrectActivityTabCondition(
                Supplier<ChromeTabbedActivity> activitySupplier,
                Supplier<Tab> expectedTabSupplier) {
            super(/* isRunOnUiThread= */ false);
            mActivitySupplier = dependOnSupplier(activitySupplier, "ChromeTabbedActivity");
            mExpectedTab = dependOnSupplier(expectedTabSupplier, "ExpectedTab");
        }

        @Override
        protected ConditionStatusWithResult<Tab> resolveWithSuppliers() {
            Tab currentActivityTab = mActivitySupplier.get().getActivityTab();
            if (currentActivityTab == null) {
                return notFulfilled("null activityTab").withoutResult();
            }

            Tab expectedTab = mExpectedTab.get();
            if (currentActivityTab == expectedTab) {
                return fulfilled("matched expected activityTab: " + currentActivityTab)
                        .withResult(currentActivityTab);
            } else {
                return notFulfilled(
                                "activityTab is "
                                        + currentActivityTab
                                        + ", expected "
                                        + expectedTab)
                        .withoutResult();
            }
        }

        @Override
        public String buildDescription() {
            return "Activity tab is the expected one";
        }
    }

    private static class AnyActivityTabCondition extends ConditionWithResult<Tab> {

        private final Supplier<ChromeTabbedActivity> mActivitySupplier;

        private AnyActivityTabCondition(Supplier<ChromeTabbedActivity> activitySupplier) {
            super(/* isRunOnUiThread= */ false);
            mActivitySupplier = dependOnSupplier(activitySupplier, "ChromeTabbedActivity");
        }

        @Override
        protected ConditionStatusWithResult<Tab> resolveWithSuppliers() {
            Tab currentActivityTab = mActivitySupplier.get().getActivityTab();
            if (currentActivityTab == null) {
                return notFulfilled("null activityTab").withoutResult();
            } else {
                return fulfilled("found activityTab " + currentActivityTab)
                        .withResult(currentActivityTab);
            }
        }

        @Override
        public String buildDescription() {
            return "Activity has an activityTab";
        }
    }
}