chromium/chrome/android/javatests/src/org/chromium/chrome/browser/tabmodel/UndoTabModelTest.java

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

package org.chromium.chrome.browser.tabmodel;

import android.os.Build.VERSION_CODES;

import androidx.test.filters.MediumTest;
import androidx.test.platform.app.InstrumentationRegistry;

import org.hamcrest.Matchers;
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.ApplicationStatus;
import org.chromium.base.ThreadUtils;
import org.chromium.base.test.util.Batch;
import org.chromium.base.test.util.CallbackHelper;
import org.chromium.base.test.util.CommandLineFlags;
import org.chromium.base.test.util.Criteria;
import org.chromium.base.test.util.CriteriaHelper;
import org.chromium.base.test.util.DisableIf;
import org.chromium.base.test.util.MinAndroidSdkLevel;
import org.chromium.base.test.util.Restriction;
import org.chromium.base.test.util.UrlUtils;
import org.chromium.chrome.browser.ChromeTabbedActivity2;
import org.chromium.chrome.browser.app.tabmodel.TabModelOrchestrator;
import org.chromium.chrome.browser.flags.ChromeSwitches;
import org.chromium.chrome.browser.multiwindow.MultiWindowTestHelper;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabLaunchType;
import org.chromium.chrome.test.ChromeJUnit4ClassRunner;
import org.chromium.chrome.test.ChromeTabbedActivityTestRule;
import org.chromium.chrome.test.batch.BlankCTATabInitialStateRule;
import org.chromium.chrome.test.util.ChromeTabUtils;
import org.chromium.content_public.browser.LoadUrlParams;
import org.chromium.ui.test.util.UiDisableIf;
import org.chromium.ui.test.util.UiRestriction;

import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException;

/**
 * Tests undo and restoring of tabs in a {@link TabModel}. These tests require native initialization
 * or multiple activities. For additional tests see {@link UndoTabModelUnitTest}.
 */
@RunWith(ChromeJUnit4ClassRunner.class)
@CommandLineFlags.Add({ChromeSwitches.DISABLE_FIRST_RUN_EXPERIENCE})
@Batch(Batch.PER_CLASS)
public class UndoTabModelTest {
    @ClassRule
    public static ChromeTabbedActivityTestRule sActivityTestRule =
            new ChromeTabbedActivityTestRule();

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

    @Before
    public void setUp() throws InterruptedException {
        // Disable snackbars from the {@link UndoBarController} which can break this test.
        sActivityTestRule.getActivity().getSnackbarManager().disableForTesting();
    }

    private static final Tab[] EMPTY = new Tab[] {};
    private static final String TEST_URL_0 = UrlUtils.encodeHtmlDataUri("<html>test_url_0.</html>");
    private static final String TEST_URL_1 = UrlUtils.encodeHtmlDataUri("<html>test_url_1.</html>");

    private void checkState(
            final TabModel model,
            final Tab[] tabsList,
            final Tab selectedTab,
            final Tab[] closingTabs,
            final Tab[] fullTabsList,
            final Tab fullSelectedTab) {
        // Keeping these checks on the test thread so the stacks are useful for identifying
        // failures.

        // Check the selected tab.
        Assert.assertEquals("Wrong selected tab", selectedTab, TabModelUtils.getCurrentTab(model));

        // Check the list of tabs.
        Assert.assertEquals("Incorrect number of tabs", tabsList.length, model.getCount());
        for (int i = 0; i < tabsList.length; i++) {
            Assert.assertEquals(
                    "Unexpected tab at " + i, tabsList[i].getId(), model.getTabAt(i).getId());
        }

        // Check the list of tabs we expect to be closing.
        for (int i = 0; i < closingTabs.length; i++) {
            int id = closingTabs[i].getId();
            Assert.assertTrue("Tab " + id + " not in closing list", model.isClosurePending(id));
        }

        TabList fullModel = model.getComprehensiveModel();

        // Check the comprehensive selected tab.
        Assert.assertEquals(
                "Wrong selected tab", fullSelectedTab, TabModelUtils.getCurrentTab(fullModel));

        // Check the comprehensive list of tabs.
        Assert.assertEquals("Incorrect number of tabs", fullTabsList.length, fullModel.getCount());
        for (int i = 0; i < fullModel.getCount(); i++) {
            int id = fullModel.getTabAt(i).getId();
            Assert.assertEquals("Unexpected tab at " + i, fullTabsList[i].getId(), id);
        }
    }

    private void createTabOnUiThread(final ChromeTabCreator tabCreator) {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    tabCreator.createNewTab(
                            new LoadUrlParams("about:blank"), TabLaunchType.FROM_CHROME_UI, null);
                });
    }

    private void closeTabOnUiThread(final TabModel model, final Tab tab, final boolean undoable)
            throws TimeoutException {
        // Check preconditions.
        Assert.assertFalse(tab.isClosing());
        Assert.assertTrue(tab.isInitialized());
        Assert.assertFalse(model.isClosurePending(tab.getId()));
        Assert.assertNotNull(model.getTabById(tab.getId()));

        final CallbackHelper didReceivePendingClosureHelper = new CallbackHelper();
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    model.addObserver(
                            new TabModelObserver() {
                                @Override
                                public void tabPendingClosure(Tab tab) {
                                    didReceivePendingClosureHelper.notifyCalled();
                                }
                            });

                    // Take action.
                    model.closeTabs(TabClosureParams.closeTab(tab).allowUndo(undoable).build());
                });

        boolean didMakePending = undoable && model.supportsPendingClosures();

        // Make sure the TabModel throws a tabPendingClosure callback if necessary.
        if (didMakePending) didReceivePendingClosureHelper.waitForCallback(0);

        // Check post conditions
        Assert.assertEquals(didMakePending, model.isClosurePending(tab.getId()));
        Assert.assertNull(model.getTabById(tab.getId()));
        Assert.assertTrue(tab.isClosing());
        Assert.assertEquals(didMakePending, tab.isInitialized());
    }

    private void saveStateOnUiThread(final TabModelOrchestrator orchestrator) {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    orchestrator.saveState();
                });

        TabModelSelector selector = orchestrator.getTabModelSelector();
        for (int i = 0; i < selector.getModels().size(); i++) {
            TabModel model = selector.getModels().get(i);
            TabList tabs = model.getComprehensiveModel();
            for (int j = 0; j < tabs.getCount(); j++) {
                Assert.assertFalse(model.isClosurePending(tabs.getTabAt(j).getId()));
            }
        }
    }

    private void openMostRecentlyClosedTabOnUiThread(final TabModelSelector selector) {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    selector.getCurrentModel().openMostRecentlyClosedEntry();
                });
    }

    // Helper class that notifies after the tab is closed, and a tab restore service entry has been
    // created in tab restore service.
    private static class TabClosedObserver implements TabModelObserver {
        private CallbackHelper mTabClosedCallback;

        public TabClosedObserver(CallbackHelper closedCallback) {
            mTabClosedCallback = closedCallback;
        }

        @Override
        public void onFinishingTabClosure(Tab tab) {
            mTabClosedCallback.notifyCalled();
        }
    }

    /**
     * Test calling {@link TabModelOrchestrator#saveState()} commits all pending closures: Action
     * Model List Close List Comprehensive List 1. Initial State [ 0 1s ] - [ 0 1s ] 2. CloseTab(0,
     * allow undo) [ 1s ] [ 0 ] [ 0 1s ] 3. SaveState [ 1s ] - [ 1s ]
     */
    @Test
    @MediumTest
    @Restriction(UiRestriction.RESTRICTION_TYPE_PHONE) // See crbug.com/633607
    public void testSaveStateCommitsUndos() throws TimeoutException, ExecutionException {
        TabModelOrchestrator orchestrator =
                ThreadUtils.runOnUiThreadBlocking(
                        () ->
                                sActivityTestRule
                                        .getActivity()
                                        .getTabModelOrchestratorSupplier()
                                        .get());
        TabModelSelector selector = orchestrator.getTabModelSelector();
        TabModel model = selector.getModel(false);
        ChromeTabCreator tabCreator =
                ThreadUtils.runOnUiThreadBlocking(
                        () -> sActivityTestRule.getActivity().getTabCreator(false));
        createTabOnUiThread(tabCreator);

        Tab tab0 = model.getTabAt(0);
        Tab tab1 = model.getTabAt(1);

        Tab[] fullList = new Tab[] {tab0, tab1};

        // 1.
        checkState(model, new Tab[] {tab0, tab1}, tab1, EMPTY, fullList, tab1);

        // 2.
        closeTabOnUiThread(model, tab0, true);
        checkState(model, new Tab[] {tab1}, tab1, EMPTY, fullList, tab1);

        // 3.
        saveStateOnUiThread(orchestrator);
        fullList = new Tab[] {tab1};
        checkState(model, new Tab[] {tab1}, tab1, EMPTY, fullList, tab1);
        Assert.assertTrue(tab0.isClosing());
        Assert.assertFalse(tab0.isInitialized());
    }

    /** Test opening recently closed tab using native tab restore service. */
    @Test
    @MediumTest
    public void testOpenRecentlyClosedTabNative() throws TimeoutException {
        final TabModelSelector selector = sActivityTestRule.getActivity().getTabModelSelector();
        final TabModel model = selector.getModel(false);

        // Create new tab and wait until it's loaded.
        // Native can only successfully recover the tab after a page load has finished and
        // it has navigation history.
        ChromeTabUtils.fullyLoadUrlInNewTab(
                InstrumentationRegistry.getInstrumentation(),
                sActivityTestRule.getActivity(),
                TEST_URL_0,
                false);

        // Close the tab, and commit pending closure.
        Assert.assertEquals(model.getCount(), 2);
        closeTabOnUiThread(model, model.getTabAt(1), false);
        Assert.assertEquals(1, model.getCount());
        Tab tab0 = model.getTabAt(0);
        Tab[] tabs = new Tab[] {tab0};
        checkState(model, tabs, tab0, EMPTY, tabs, tab0);

        // Recover the page.
        openMostRecentlyClosedTabOnUiThread(selector);

        Assert.assertEquals(2, model.getCount());
        tab0 = model.getTabAt(0);
        Tab tab1 = model.getTabAt(1);
        tabs = new Tab[] {tab0, tab1};
        Assert.assertEquals(TEST_URL_0, ChromeTabUtils.getUrlStringOnUiThread(tab1));
        checkState(model, tabs, tab1, EMPTY, tabs, tab1);
    }

    /**
     * Test opening recently closed tab when we have multiple windows. | Action | Result 1. Create
     * second window. | 2. Open tab in window 1. | 3. Open tab in window 2. | 4. Close tab in window
     * 1. | 5. Close tab in window 2. | 6. Restore tab. | Tab restored in window 2. 7. Restore tab.
     * | Tab restored in window 1.
     */
    @Test
    @MediumTest
    @DisableIf.Device(type = {UiDisableIf.TABLET}) // https://crbug.com/338997949
    @DisableIf.Build(sdk_is_greater_than = VERSION_CODES.S_V2) // https://crbug.com/1297370
    @CommandLineFlags.Add(ChromeSwitches.DISABLE_TAB_MERGING_FOR_TESTING)
    public void testOpenRecentlyClosedTabMultiWindow() throws TimeoutException {
        final ChromeTabbedActivity2 secondActivity =
                MultiWindowTestHelper.createSecondChromeTabbedActivity(
                        sActivityTestRule.getActivity());

        // Wait for the second window to be fully initialized.
        CriteriaHelper.pollUiThread(
                () -> secondActivity.getTabModelSelector().isTabStateInitialized());
        // First window context.
        final TabModelSelector firstSelector =
                sActivityTestRule.getActivity().getTabModelSelector();
        final TabModel firstModel = firstSelector.getModel(false);

        // Second window context.
        final TabModelSelector secondSelector = secondActivity.getTabModelSelector();
        final TabModel secondModel = secondSelector.getModel(false);

        // Create tabs.
        ChromeTabUtils.fullyLoadUrlInNewTab(
                InstrumentationRegistry.getInstrumentation(),
                sActivityTestRule.getActivity(),
                TEST_URL_0,
                false);
        ChromeTabUtils.fullyLoadUrlInNewTab(
                InstrumentationRegistry.getInstrumentation(), secondActivity, TEST_URL_1, false);

        Assert.assertEquals("Unexpected number of tabs in first window.", 2, firstModel.getCount());
        Assert.assertEquals(
                "Unexpected number of tabs in second window.", 2, secondModel.getCount());

        // Close one tab in the first window.
        closeTabOnUiThread(firstModel, firstModel.getTabAt(1), false);
        Assert.assertEquals("Unexpected number of tabs in first window.", 1, firstModel.getCount());
        Assert.assertEquals(
                "Unexpected number of tabs in second window.", 2, secondModel.getCount());

        // Close one tab in the second window.
        closeTabOnUiThread(secondModel, secondModel.getTabAt(1), false);
        Assert.assertEquals("Unexpected number of tabs in first window.", 1, firstModel.getCount());
        Assert.assertEquals(
                "Unexpected number of tabs in second window.", 1, secondModel.getCount());

        // Restore one tab to the second selector.
        openMostRecentlyClosedTabOnUiThread(secondSelector);
        Assert.assertEquals("Unexpected number of tabs in first window.", 1, firstModel.getCount());
        Assert.assertEquals(
                "Unexpected number of tabs in second window.", 2, secondModel.getCount());

        // Restore one more tab to the first selector.
        openMostRecentlyClosedTabOnUiThread(firstSelector);

        // Check final states of both windows.
        Tab firstModelTab = firstModel.getTabAt(0);
        Tab secondModelTab = secondModel.getTabAt(0);
        Tab[] firstWindowTabs = new Tab[] {firstModelTab, firstModel.getTabAt(1)};
        Tab[] secondWindowTabs = new Tab[] {secondModelTab, secondModel.getTabAt(1)};
        checkState(
                firstModel,
                firstWindowTabs,
                firstModel.getTabAt(1),
                EMPTY,
                firstWindowTabs,
                firstModel.getTabAt(1));
        checkState(
                secondModel,
                secondWindowTabs,
                secondModel.getTabAt(1),
                EMPTY,
                secondWindowTabs,
                secondModel.getTabAt(1));
        Assert.assertEquals(TEST_URL_0, ChromeTabUtils.getUrlStringOnUiThread(firstWindowTabs[1]));
        Assert.assertEquals(TEST_URL_1, ChromeTabUtils.getUrlStringOnUiThread(secondWindowTabs[1]));

        secondActivity.finishAndRemoveTask();
    }

    /**
     * Test restoring closed tab from a closed window. | Action | Result 1. Create second window. |
     * 2. Open tab in window 2. | 3. Close tab in window 2. | 4. Close second window. | 5. Restore
     * tab. | Tab restored in first window.
     */
    @Test
    @MediumTest
    @DisableIf.Device(type = {UiDisableIf.TABLET}) // https://crbug.com/338997949
    @DisableIf.Build(sdk_is_greater_than = VERSION_CODES.S_V2) // https://crbug.com/1297370
    @MinAndroidSdkLevel(24)
    @CommandLineFlags.Add(ChromeSwitches.DISABLE_TAB_MERGING_FOR_TESTING)
    public void testOpenRecentlyClosedTabMultiWindowFallback() throws TimeoutException {
        final ChromeTabbedActivity2 secondActivity =
                MultiWindowTestHelper.createSecondChromeTabbedActivity(
                        sActivityTestRule.getActivity());
        // Wait for the second window to be fully initialized.
        CriteriaHelper.pollUiThread(
                () -> secondActivity.getTabModelSelector().isTabStateInitialized());

        // First window context.
        final TabModelSelector firstSelector =
                sActivityTestRule.getActivity().getTabModelSelector();
        final TabModel firstModel = firstSelector.getModel(false);

        // Second window context.
        final TabModel secondModel = secondActivity.getTabModelSelector().getModel(false);

        // Create tab on second window.
        ChromeTabUtils.fullyLoadUrlInNewTab(
                InstrumentationRegistry.getInstrumentation(), secondActivity, TEST_URL_1, false);
        Assert.assertEquals("Window 2 should have 2 tab.", 2, secondModel.getCount());

        // Close tab in second window, wait until tab restore service history is created.
        CallbackHelper closedCallback = new CallbackHelper();
        ThreadUtils.runOnUiThreadBlocking(
                () -> secondModel.addObserver(new TabClosedObserver(closedCallback)));
        closeTabOnUiThread(secondModel, secondModel.getTabAt(1), false);
        closedCallback.waitForCallback(0);

        Assert.assertEquals("Window 2 should have 1 tab.", 1, secondModel.getCount());

        // Closed the second window. Must wait until it's totally closed.
        int numExpectedActivities = ApplicationStatus.getRunningActivities().size() - 1;
        secondActivity.finishAndRemoveTask();
        CriteriaHelper.pollUiThread(
                () -> {
                    Criteria.checkThat(
                            ApplicationStatus.getRunningActivities().size(),
                            Matchers.is(numExpectedActivities));
                });
        Assert.assertEquals("Window 1 should have 1 tab.", 1, firstModel.getCount());

        // Restore closed tab from second window. It should be created in first window.
        openMostRecentlyClosedTabOnUiThread(firstSelector);
        Assert.assertEquals(
                "Closed tab in second window should be restored in the first window.",
                2,
                firstModel.getCount());
        Tab tab0 = firstModel.getTabAt(0);
        Tab tab1 = firstModel.getTabAt(1);
        Tab[] firstWindowTabs = new Tab[] {tab0, tab1};
        // After restoring tab1, it should selected as the current tab.
        checkState(firstModel, firstWindowTabs, tab1, EMPTY, firstWindowTabs, tab1);
        Assert.assertEquals(TEST_URL_1, ChromeTabUtils.getUrlStringOnUiThread(tab1));
    }
}