chromium/chrome/android/javatests/src/org/chromium/chrome/browser/WarmupManagerTest.java

// Copyright 2016 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;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotEquals;

import android.content.Context;

import androidx.test.annotation.UiThreadTest;
import androidx.test.filters.MediumTest;
import androidx.test.filters.SmallTest;
import androidx.test.platform.app.InstrumentationRegistry;

import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.ClassRule;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;

import org.chromium.base.ThreadUtils;
import org.chromium.base.task.PostTask;
import org.chromium.base.task.TaskTraits;
import org.chromium.base.test.params.ParameterAnnotations.UseMethodParameter;
import org.chromium.base.test.params.ParameterAnnotations.UseRunnerDelegate;
import org.chromium.base.test.params.ParameterProvider;
import org.chromium.base.test.params.ParameterSet;
import org.chromium.base.test.params.ParameterizedRunner;
import org.chromium.base.test.util.Batch;
import org.chromium.base.test.util.CommandLineFlags;
import org.chromium.base.test.util.CriteriaHelper;
import org.chromium.base.test.util.Feature;
import org.chromium.base.test.util.HistogramWatcher;
import org.chromium.base.test.util.Restriction;
import org.chromium.chrome.browser.WarmupManager.SpareTabFinalStatus;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.flags.ChromeSwitches;
import org.chromium.chrome.browser.init.ChromeBrowserInitializer;
import org.chromium.chrome.browser.profiles.OTRProfileID;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.profiles.ProfileManager;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabLaunchType;
import org.chromium.chrome.browser.tabmodel.TabCreator;
import org.chromium.chrome.browser.tabmodel.TabModel;
import org.chromium.chrome.browser.tasks.tab_groups.TabGroupModelFilter;
import org.chromium.chrome.test.ChromeJUnit4RunnerDelegate;
import org.chromium.chrome.test.ChromeTabbedActivityTestRule;
import org.chromium.chrome.test.R;
import org.chromium.chrome.test.batch.BlankCTATabInitialStateRule;
import org.chromium.chrome.test.util.ChromeTabUtils;
import org.chromium.chrome.test.util.browser.signin.AccountManagerTestRule;
import org.chromium.content_public.browser.LoadUrlParams;
import org.chromium.content_public.browser.WebContents;
import org.chromium.content_public.browser.test.util.WebContentsUtils;
import org.chromium.net.test.EmbeddedTestServer;
import org.chromium.net.test.util.TestWebServer;
import org.chromium.ui.display.DisplayUtil;
import org.chromium.ui.test.util.DeviceRestriction;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;

/** Tests for {@link WarmupManager} */
@RunWith(ParameterizedRunner.class)
@Batch(Batch.PER_CLASS)
@UseRunnerDelegate(ChromeJUnit4RunnerDelegate.class)
@CommandLineFlags.Add({ChromeSwitches.DISABLE_FIRST_RUN_EXPERIENCE})
public class WarmupManagerTest {
    @ClassRule
    public static ChromeTabbedActivityTestRule sActivityTestRule =
            new ChromeTabbedActivityTestRule();

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

    public enum ProfileType {
        REGULAR_PROFILE,
        PRIMARY_OTR_PROFILE,
        NON_PRIMARY_OTR_PROFILE
    }

    private static final String HISTOGRAM_SPARE_TAB_FINAL_STATUS = "Android.SpareTab.FinalStatus";
    private static final String MAIN_FRAME_FILE = "/main_frame.html";

    /** Provides parameter for testPreconnect to run it with both regular and incognito profiles. */
    public static class ProfileParams implements ParameterProvider {
        @Override
        public Iterable<ParameterSet> getParameters() {
            return Arrays.asList(
                    new ParameterSet()
                            .value(ProfileType.PRIMARY_OTR_PROFILE.toString())
                            .name("PrimaryIncognitoProfile"),
                    new ParameterSet()
                            .value(ProfileType.NON_PRIMARY_OTR_PROFILE.toString())
                            .name("NonPrimaryIncognitoProfile"),
                    new ParameterSet()
                            .value(ProfileType.REGULAR_PROFILE.toString())
                            .name("RegularProfile"));
        }
    }

    @Rule
    public final AccountManagerTestRule mAccountManagerTestRule = new AccountManagerTestRule();

    private WarmupManager mWarmupManager;
    private Context mContext;

    private TestWebServer mWebServer;
    private TabModel mTabModel;
    private TabGroupModelFilter mTabGroupModelFilter;

    @Before
    public void setUp() throws Exception {
        mTabModel = sActivityTestRule.getActivity().getTabModelSelector().getModel(false);

        mTabGroupModelFilter =
                (TabGroupModelFilter)
                        sActivityTestRule
                                .getActivity()
                                .getTabModelSelector()
                                .getTabModelFilterProvider()
                                .getTabModelFilter(false);

        // Unlike most of Chrome, the WarmupManager inflates layouts with the application context.
        // This is because the inflation happens before an activity exists. If you're trying to fix
        // a failing test, it's important to not add extra theme/style information to this context
        // in this test because it could hide a real production issue. See https://crbug.com/1246329
        // for an example.
        mContext =
                InstrumentationRegistry.getInstrumentation()
                        .getTargetContext()
                        .getApplicationContext();
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    ChromeBrowserInitializer.getInstance().handleSynchronousStartup();
                    mWarmupManager = WarmupManager.getInstance();
                });
        mWebServer = TestWebServer.start();
    }

    @After
    public void tearDown() {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    mWarmupManager.destroySpareWebContents();
                    mWarmupManager.destroySpareTab();
                    WarmupManager.deInitForTesting();
                });
        mWebServer.shutdown();
    }

    private void assertOrderValid(boolean expectedState) {
        boolean isOrderValid =
                ThreadUtils.runOnUiThreadBlocking(
                        () -> {
                            return mTabGroupModelFilter.isOrderValid();
                        });
        assertEquals(expectedState, isOrderValid);
    }

    /**
     * Helper methods to create tabs, adding new tabs, and getting current tabs in the tab model.
     */
    private void prepareTabs(List<Integer> tabsPerGroup) {
        for (int tabsToCreate : tabsPerGroup) {
            List<Tab> tabs = new ArrayList<>();
            for (int i = 0; i < tabsToCreate; i++) {
                Tab tab =
                        ChromeTabUtils.fullyLoadUrlInNewTab(
                                InstrumentationRegistry.getInstrumentation(),
                                sActivityTestRule.getActivity(),
                                "about:blank",
                                /* incognito= */ false);
                tabs.add(tab);
            }
            ThreadUtils.runOnUiThreadBlocking(
                    () -> {
                        mTabGroupModelFilter.mergeListOfTabsToGroup(tabs, tabs.get(0), false);
                    });
        }
    }

    private Tab addTabAt(int index, Tab parent) {
        final String data = "<html><head></head><body><p>Hello World</p></body></html>";
        final String url = mWebServer.setResponse(MAIN_FRAME_FILE, data, null);

        Tab tab =
                ThreadUtils.runOnUiThreadBlocking(
                        () -> {
                            @TabLaunchType
                            int type =
                                    parent != null
                                            ? TabLaunchType.FROM_TAB_GROUP_UI
                                            : TabLaunchType.FROM_CHROME_UI;
                            TabCreator tabCreator =
                                    sActivityTestRule
                                            .getActivity()
                                            .getTabCreator(/* incognito= */ false);
                            return tabCreator.createNewTab(
                                    new LoadUrlParams(url), type, parent, index);
                        });
        ChromeTabUtils.waitForTabPageLoaded(tab, url);
        return tab;
    }

    private List<Tab> getCurrentTabs() {
        List<Tab> tabs = new ArrayList<>();
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    for (int i = 0; i < mTabModel.getCount(); i++) {
                        tabs.add(mTabModel.getTabAt(i));
                    }
                });
        return tabs;
    }

    private static Profile getNonPrimaryOTRProfile() {
        return ThreadUtils.runOnUiThreadBlocking(
                (Callable<Profile>)
                        () -> {
                            OTRProfileID otrProfileID = OTRProfileID.createUnique("CCT:Incognito");
                            return ProfileManager.getLastUsedRegularProfile()
                                    .getOffTheRecordProfile(
                                            otrProfileID, /* createIfNeeded= */ true);
                        });
    }

    private static Profile getPrimaryOTRProfile() {
        return ThreadUtils.runOnUiThreadBlocking(
                (Callable<Profile>)
                        () ->
                                ProfileManager.getLastUsedRegularProfile()
                                        .getPrimaryOTRProfile(/* createIfNeeded= */ true));
    }

    private static Profile getRegularProfile() {
        return ThreadUtils.runOnUiThreadBlocking(
                (Callable<Profile>) () -> ProfileManager.getLastUsedRegularProfile());
    }

    private static Profile getProfile(ProfileType profileType) {
        switch (profileType) {
            case NON_PRIMARY_OTR_PROFILE:
                return getNonPrimaryOTRProfile();
            case PRIMARY_OTR_PROFILE:
                return getPrimaryOTRProfile();
            default:
                return getRegularProfile();
        }
    }

    @Test
    @SmallTest
    public void testCreateAndTakeSpareRenderer() {
        final AtomicBoolean isRenderFrameLive = new AtomicBoolean();
        final AtomicReference<WebContents> webContentsReference = new AtomicReference<>();

        PostTask.runOrPostTask(
                TaskTraits.UI_DEFAULT,
                () -> {
                    mWarmupManager.createSpareWebContents(sActivityTestRule.getProfile(false));
                    Assert.assertTrue(mWarmupManager.hasSpareWebContents());
                    WebContents webContents = mWarmupManager.takeSpareWebContents(false, false);
                    Assert.assertNotNull(webContents);
                    Assert.assertFalse(mWarmupManager.hasSpareWebContents());

                    if (webContents.getMainFrame().isRenderFrameLive()) {
                        isRenderFrameLive.set(true);
                    }

                    webContentsReference.set(webContents);
                });
        CriteriaHelper.pollUiThread(
                () -> isRenderFrameLive.get(), "Spare renderer is not initialized");
        PostTask.runOrPostTask(TaskTraits.UI_DEFAULT, () -> webContentsReference.get().destroy());
    }

    /** Tests that taking a spare WebContents makes it unavailable to subsequent callers. */
    @Test
    @SmallTest
    @UiThreadTest
    public void testTakeSpareWebContents() {
        mWarmupManager.createSpareWebContents(sActivityTestRule.getProfile(false));
        WebContents webContents = mWarmupManager.takeSpareWebContents(false, false);
        Assert.assertNotNull(webContents);
        Assert.assertFalse(mWarmupManager.hasSpareWebContents());
        webContents.destroy();
    }

    @Test
    @SmallTest
    @UiThreadTest
    public void testTakeSpareWebContentsChecksArguments() {
        mWarmupManager.createSpareWebContents(sActivityTestRule.getProfile(false));
        Assert.assertNull(mWarmupManager.takeSpareWebContents(true, false));
        Assert.assertNull(mWarmupManager.takeSpareWebContents(true, true));
        Assert.assertTrue(mWarmupManager.hasSpareWebContents());
        Assert.assertNotNull(mWarmupManager.takeSpareWebContents(false, true));
        Assert.assertFalse(mWarmupManager.hasSpareWebContents());
    }

    @Test
    @SmallTest
    @UiThreadTest
    public void testClearsDeadWebContents() {
        mWarmupManager.createSpareWebContents(sActivityTestRule.getProfile(false));
        WebContentsUtils.simulateRendererKilled(mWarmupManager.mSpareWebContents);
        Assert.assertNull(mWarmupManager.takeSpareWebContents(false, false));
    }

    /** Checks that the View inflation works. */
    @Test
    @SmallTest
    @UiThreadTest
    public void testInflateLayout() {
        int layoutId = R.layout.custom_tabs_control_container;
        int toolbarId = R.layout.custom_tabs_toolbar;
        mWarmupManager.initializeViewHierarchy(mContext, layoutId, toolbarId);
        Assert.assertTrue(mWarmupManager.hasViewHierarchyWithToolbar(layoutId, mContext));
    }

    /**
     * Tests that pre-connects can be initiated from the Java side.
     *
     * @param profileParameter String value to indicate which profile to use for pre-connect. This
     *     is passed by {@link ProfileParams}.
     * @throws InterruptedException May come from tryAcquire method call.
     */
    @Test
    @SmallTest
    @UseMethodParameter(ProfileParams.class)
    public void testPreconnect(String profileParameter) throws InterruptedException {
        ProfileType profileType = ProfileType.valueOf(profileParameter);
        Profile profile = getProfile(profileType);
        EmbeddedTestServer server = new EmbeddedTestServer();
        // The predictor prepares 1 or 2 connections when asked to preconnect. Initializes the
        // semaphore to be unlocked after 1 or 2 connections.
        int expectedConnections =
                ChromeFeatureList.isEnabled(
                                ChromeFeatureList.LOADING_PREDICTOR_LIMIT_PRECONNECT_SOCKET_COUNT)
                        ? 1
                        : 2;
        final Semaphore connectionsSemaphore = new Semaphore(1 - expectedConnections);
        // Cannot use EmbeddedTestServer#createAndStartServer(), as we need to add the
        // connection listener.
        server.initializeNative(mContext, EmbeddedTestServer.ServerHTTPSSetting.USE_HTTP);
        server.addDefaultHandlers("");
        server.setConnectionListener(
                new EmbeddedTestServer.ConnectionListener() {
                    @Override
                    public void acceptedSocket(long socketId) {
                        connectionsSemaphore.release();
                    }
                });
        server.start();
        final String url = server.getURL("/hello_world.html");
        PostTask.runOrPostTask(
                TaskTraits.UI_DEFAULT,
                () -> {
                    mWarmupManager.maybePreconnectUrlAndSubResources(profile, url);
                });
        boolean isAcquired = connectionsSemaphore.tryAcquire(5, TimeUnit.SECONDS);
        if (profileType == ProfileType.REGULAR_PROFILE && !isAcquired) {
            // Starts at -1.
            int actualConnections = connectionsSemaphore.availablePermits() + 1;
            Assert.fail(
                    String.format(
                            "Pre-connect failed for regular profile: Expected %d connections, got"
                                    + " %d",
                            expectedConnections, actualConnections));
        } else if (profileType != ProfileType.REGULAR_PROFILE && isAcquired) {
            Assert.fail("Pre-connect should fail for incognito profiles.");
        }
    }

    // Test to check the functionality of spare tab creation with initializing renderer.
    // TODO(crbug.com/40255340): Add tests to track navigation related WebContentsObserver events
    // with spare tab creation.
    @Test
    @MediumTest
    @Feature({"SpareTab"})
    public void testCreateAndTakeSpareTabWithInitializeRenderer() {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    Profile profile = getProfile(ProfileType.REGULAR_PROFILE);
                    mWarmupManager.createRegularSpareTab(profile);
                    Assert.assertTrue(mWarmupManager.hasSpareTab(profile));
                    Tab tab = mWarmupManager.takeSpareTab(profile, TabLaunchType.FROM_CHROME_UI);
                    WebContents webContents = tab.getWebContents();
                    Assert.assertNotNull(tab);
                    Assert.assertNotNull(webContents);
                    Assert.assertFalse(mWarmupManager.hasSpareTab(profile));
                    Assert.assertEquals(TabLaunchType.FROM_CHROME_UI, tab.getLaunchType());

                    // RenderFrame should become live synchronously during WebContents creation when
                    // SPARE_TAB_INITIALIZE_RENDERER is set.
                    Assert.assertTrue(webContents.getMainFrame().isRenderFrameLive());
                    tab.destroy();
                });
    }

    /** Tests that taking a spare Tab makes it unavailable to subsequent callers. */
    @Test
    @MediumTest
    @Feature({"SpareTab"})
    @UiThreadTest
    public void testTakeSpareTab() {
        var histogramWatcher =
                HistogramWatcher.newSingleRecordWatcher(
                        HISTOGRAM_SPARE_TAB_FINAL_STATUS, SpareTabFinalStatus.TAB_USED);
        Profile profile = getProfile(ProfileType.REGULAR_PROFILE);
        mWarmupManager.createRegularSpareTab(profile);
        Tab tab = mWarmupManager.takeSpareTab(profile, TabLaunchType.FROM_CHROME_UI);
        Assert.assertNotNull(tab);
        Assert.assertFalse(mWarmupManager.hasSpareTab(profile));
        Assert.assertEquals(TabLaunchType.FROM_CHROME_UI, tab.getLaunchType());
        histogramWatcher.assertExpected();
        tab.destroy();
    }

    /**
     * Tests that deleting a spare Tab makes it unavailable to subsequent callers and record correct
     * metrics.
     */
    @Test
    @MediumTest
    @Feature({"SpareTab"})
    @UiThreadTest
    public void testDestroySpareTab() {
        var histogramWatcher =
                HistogramWatcher.newSingleRecordWatcher(
                        HISTOGRAM_SPARE_TAB_FINAL_STATUS, SpareTabFinalStatus.TAB_DESTROYED);

        Profile profile = getProfile(ProfileType.REGULAR_PROFILE);
        mWarmupManager.createRegularSpareTab(profile);
        Assert.assertTrue(mWarmupManager.hasSpareTab(profile));
        Assert.assertFalse(mWarmupManager.hasSpareTab(getProfile(ProfileType.PRIMARY_OTR_PROFILE)));

        // Destroy the created spare tab.
        mWarmupManager.destroySpareTab();
        Assert.assertFalse(mWarmupManager.hasSpareTab(profile));

        histogramWatcher.assertExpected();
    }

    /** Tests that when SpareTab is not destroyed when the renderer is killed. */
    @Test
    @MediumTest
    @Feature({"SpareTab"})
    @UiThreadTest
    public void testDontDestroySpareTabWhenRendererKilled() {
        var histogramWatcher =
                HistogramWatcher.newSingleRecordWatcher(
                        HISTOGRAM_SPARE_TAB_FINAL_STATUS, SpareTabFinalStatus.TAB_USED);

        Profile profile = getProfile(ProfileType.REGULAR_PROFILE);
        mWarmupManager.createRegularSpareTab(profile);

        // Kill the renderer process, this shouldn't kill the associated spare tab and record
        // TAB_CREATED status.
        WebContentsUtils.simulateRendererKilled(mWarmupManager.mSpareTab.getWebContents());
        Tab tab = mWarmupManager.takeSpareTab(profile, TabLaunchType.FROM_CHROME_UI);
        Assert.assertNotNull(tab);
        histogramWatcher.assertExpected();
        tab.destroy();
    }

    /** Tests that we are able to load url in the spare tab once it is created. */
    @Test
    @MediumTest
    @Feature({"SpareTab"})
    public void testLoadURLInSpareTab() {
        var histogramWatcher =
                HistogramWatcher.newSingleRecordWatcher(
                        HISTOGRAM_SPARE_TAB_FINAL_STATUS, SpareTabFinalStatus.TAB_USED);
        Assert.assertNotNull(sActivityTestRule.getActivity().getCurrentTabCreator());

        prepareTabs(Arrays.asList(new Integer[] {3, 1}));
        List<Tab> tabs = getCurrentTabs();

        Profile profile = getProfile(ProfileType.REGULAR_PROFILE);
        // Create spare tab so that it can be used for navigation from TAB_GROUP_UI.
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    mWarmupManager.createRegularSpareTab(profile);
                    Assert.assertTrue(mWarmupManager.hasSpareTab(profile));
                });

        // Tab 0
        // Tab (tab added here), 1, 2, 3
        // Tab 4 - this uses spare tab.
        Tab tab = addTabAt(/* index= */ 0, /* parent= */ tabs.get(1));
        tabs.add(1, tab);
        assertEquals(tabs, getCurrentTabs());
        assertOrderValid(true);
        Assert.assertEquals(TabLaunchType.FROM_TAB_GROUP_UI, tab.getLaunchType());

        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    Assert.assertFalse(mWarmupManager.hasSpareTab(profile));
                });
        histogramWatcher.assertExpected();
    }

    /** Tests that page load metrics are recorded when the spare tab is used for navigation */
    @Test
    @MediumTest
    @Feature({"SpareTab"})
    public void testMetricsRecordedWithSpareTab() {
        Assert.assertNotNull(sActivityTestRule.getActivity().getCurrentTabCreator());

        prepareTabs(Arrays.asList(new Integer[] {1, 1}));
        List<Tab> tabs = getCurrentTabs();

        Profile profile = getProfile(ProfileType.REGULAR_PROFILE);
        // Create spare tab so that it can be used for navigation from TAB_GROUP_UI.
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    mWarmupManager.createRegularSpareTab(profile);
                    Assert.assertTrue(mWarmupManager.hasSpareTab(profile));
                });

        // Check that the First Paint (FP) and First Contentful Paint (FCP) metrics are recorded
        // correctly when using the SpareTab feature.
        var pageLoadHistogramWatcher =
                HistogramWatcher.newBuilder()
                        .expectAnyRecordTimes("PageLoad.PaintTiming.NavigationToFirstPaint", 1)
                        .expectAnyRecordTimes(
                                "PageLoad.PaintTiming.NavigationToFirstContentfulPaint", 1)
                        .build();

        // Navigate and this should record PageLoadMetrics.
        Tab tab = addTabAt(/* index= */ 0, /* parent= */ tabs.get(1));
        tabs.add(1, tab);
        Assert.assertEquals(TabLaunchType.FROM_TAB_GROUP_UI, tab.getLaunchType());

        // PageLoadMetrics should be recorded when SpareTab is used for navigation.
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    Assert.assertFalse(mWarmupManager.hasSpareTab(profile));
                });
        pageLoadHistogramWatcher.pollInstrumentationThreadUntilSatisfied();
    }

    @Test
    @SmallTest
    @Restriction({DeviceRestriction.RESTRICTION_TYPE_NON_AUTO})
    public void testApplyContextOverridesOnNonAutomotive() {
        Context baseContext = mContext.getApplicationContext();
        Context updatedContext = WarmupManager.applyContextOverrides(baseContext);

        assertEquals(
                "The updated context should be the same as the original context.",
                baseContext,
                updatedContext);
    }

    @Test
    @SmallTest
    @Restriction({DeviceRestriction.RESTRICTION_TYPE_AUTO})
    public void testApplyContextOverridesOnAutomotive() {
        Context baseContext = mContext.getApplicationContext();
        Context updatedContext = WarmupManager.applyContextOverrides(baseContext);

        assertNotEquals(
                "The updated context should be different from the original context.",
                baseContext,
                updatedContext);
        assertEquals(
                "The updated context should have a scaled up densityDpi",
                (int)
                        (baseContext.getResources().getDisplayMetrics().densityDpi
                                * DisplayUtil.getUiScalingFactorForAutomotive()),
                updatedContext.getResources().getDisplayMetrics().densityDpi);
    }
}