// 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;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import android.app.Activity;
import androidx.annotation.Nullable;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.filters.SmallTest;
import org.junit.After;
import org.junit.Before;
import org.junit.ClassRule;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mockito;
import org.mockito.junit.MockitoJUnit;
import org.mockito.junit.MockitoRule;
import org.chromium.base.ThreadUtils;
import org.chromium.base.Token;
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.CriteriaHelper;
import org.chromium.base.test.util.Feature;
import org.chromium.base.test.util.RequiresRestart;
import org.chromium.chrome.browser.flags.ChromeSwitches;
import org.chromium.chrome.browser.tab.EmptyTabObserver;
import org.chromium.chrome.browser.tab.SadTab;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabLoadIfNeededCaller;
import org.chromium.chrome.browser.tab.TabObserver;
import org.chromium.chrome.browser.tab.TabSelectionType;
import org.chromium.chrome.browser.tab.TabState;
import org.chromium.chrome.browser.tab.TabStateExtractor;
import org.chromium.chrome.browser.tab.TabTestUtils;
import org.chromium.chrome.browser.tabmodel.TabClosureParams;
import org.chromium.chrome.browser.tabmodel.TabModel;
import org.chromium.chrome.browser.tabmodel.TabModelUtils;
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.ChromeApplicationTestUtils;
import org.chromium.chrome.test.util.ChromeTabUtils;
import org.chromium.chrome.test.util.RecentTabsPageTestUtils;
import org.chromium.components.embedder_support.util.UrlConstants;
import org.chromium.content_public.browser.LoadUrlParams;
import org.chromium.content_public.browser.WebContents;
import org.chromium.ui.base.WindowAndroid;
/** Tests for Tab class. */
@RunWith(ChromeJUnit4ClassRunner.class)
@CommandLineFlags.Add({ChromeSwitches.DISABLE_FIRST_RUN_EXPERIENCE})
@Batch(Batch.PER_CLASS)
public class TabTest {
@ClassRule
public static ChromeTabbedActivityTestRule sActivityTestRule =
new ChromeTabbedActivityTestRule();
@Rule
public BlankCTATabInitialStateRule mBlankCTATabInitialStateRule =
new BlankCTATabInitialStateRule(sActivityTestRule, false);
@Rule public MockitoRule mMockitoRule = MockitoJUnit.rule();
private Tab mTab;
private int mRootIdForReset;
private CallbackHelper mOnTitleUpdatedHelper;
private final TabObserver mTabObserver =
new EmptyTabObserver() {
@Override
public void onTitleUpdated(Tab tab) {
mOnTitleUpdatedHelper.notifyCalled();
}
};
private boolean isShowingSadTab() throws Exception {
return ThreadUtils.runOnUiThreadBlocking(() -> SadTab.isShowing(mTab));
}
@Before
public void setUp() throws Exception {
mTab = sActivityTestRule.getActivity().getActivityTab();
ThreadUtils.runOnUiThreadBlocking(() -> mTab.addObserver(mTabObserver));
mOnTitleUpdatedHelper = new CallbackHelper();
mRootIdForReset = mTab.getRootId();
}
@After
public void tearDown() {
ThreadUtils.runOnUiThreadBlocking(
() -> {
// Reset Root Id to what it was at the start, as it can be modified in the
// tests.
mTab.setRootId(mRootIdForReset);
mTab.removeObserver(mTabObserver);
});
}
@Test
@SmallTest
@Feature({"Tab"})
public void testTabContext() {
assertFalse(
"The tab context cannot be an activity",
mTab.getContentView().getContext() instanceof Activity);
assertNotSame(
"The tab context's theme should have been updated",
mTab.getContentView().getContext().getTheme(),
sActivityTestRule.getActivity().getApplication().getTheme());
}
@Test
@SmallTest
@Feature({"Tab"})
public void testTitleDelayUpdate() throws Throwable {
final String oldTitle = "oldTitle";
final String newTitle = "newTitle";
sActivityTestRule.loadUrl(
"data:text/html;charset=utf-8,<html><head><title>"
+ oldTitle
+ "</title></head><body/></html>");
assertEquals(
"title does not match initial title",
oldTitle,
ChromeTabUtils.getTitleOnUiThread(mTab));
int currentCallCount = mOnTitleUpdatedHelper.getCallCount();
sActivityTestRule.runJavaScriptCodeInCurrentTab("document.title='" + newTitle + "';");
mOnTitleUpdatedHelper.waitForCallback(currentCallCount);
assertEquals("title does not update", newTitle, ChromeTabUtils.getTitleOnUiThread(mTab));
}
/**
* Verifies a Tab's contents is restored when the Tab is foregrounded after its contents have
* been destroyed while backgrounded. Note that document mode is explicitly disabled, as the
* document activity may be fully recreated if its contents is killed while in the background.
*/
@Test
@SmallTest
@Feature({"Tab"})
public void testTabRestoredIfKilledWhileActivityStopped() throws Exception {
// Ensure the tab is showing before stopping the activity.
ThreadUtils.runOnUiThreadBlocking(
() -> mTab.show(TabSelectionType.FROM_NEW, TabLoadIfNeededCaller.OTHER));
assertFalse(mTab.needsReload());
assertFalse(mTab.isHidden());
assertFalse(isShowingSadTab());
// Stop the activity and simulate a killed renderer.
ChromeApplicationTestUtils.fireHomeScreenIntent(
ApplicationProvider.getApplicationContext());
ThreadUtils.runOnUiThreadBlocking(
() -> ChromeTabUtils.simulateRendererKilledForTesting(mTab));
CriteriaHelper.pollUiThread(mTab::isHidden);
assertTrue(mTab.needsReload());
assertFalse(isShowingSadTab());
ChromeApplicationTestUtils.launchChrome(ApplicationProvider.getApplicationContext());
// The tab should be restored and visible.
CriteriaHelper.pollUiThread(() -> !mTab.isHidden());
assertFalse(mTab.needsReload());
assertFalse(isShowingSadTab());
}
@Test
@SmallTest
@Feature({"Tab"})
public void testTabAttachment() {
assertNotNull(mTab.getWebContents());
assertFalse(mTab.isDetached());
detachOnUiThread(mTab);
assertNotNull(mTab.getWebContents());
assertTrue(mTab.isDetached());
attachOnUiThread(mTab);
assertNotNull(mTab.getWebContents());
assertFalse(mTab.isDetached());
}
@Test
@SmallTest
@Feature({"Tab"})
@RequiresRestart(
"crbug.com/358190587, causes BlankCTATabInitialStateRule state reset to fail flakily.")
public void testNativePageTabAttachment() {
sActivityTestRule.loadUrl(UrlConstants.RECENT_TABS_URL);
RecentTabsPageTestUtils.waitForRecentTabsPageLoaded(mTab);
assertNotNull(mTab.getWebContents());
assertFalse(mTab.isDetached());
detachOnUiThread(mTab);
assertNotNull(mTab.getWebContents());
assertTrue(mTab.isDetached());
attachOnUiThread(mTab);
assertNotNull(mTab.getWebContents());
assertFalse(mTab.isDetached());
}
@Test
@SmallTest
@Feature({"Tab"})
public void testFrozenTabAttachment() {
String url =
sActivityTestRule.getTestServer().getURL("/chrome/test/data/android/about.html");
Tab tab = createSecondFrozenTab(url);
assertNull(tab.getWebContents());
assertFalse(tab.isDetached());
detachOnUiThread(tab);
assertNull(tab.getWebContents());
assertTrue(tab.isDetached());
attachOnUiThread(tab);
assertNull(tab.getWebContents());
assertFalse(tab.isDetached());
}
@Test
@SmallTest
@Feature({"Tab"})
public void testRestoreTabState() {
TabState tabState =
ThreadUtils.runOnUiThreadBlocking(
() -> {
return TabStateExtractor.from(mTab);
});
tabState.timestampMillis = 437289L;
tabState.lastNavigationCommittedTimestampMillis = 748932L;
tabState.rootId = 5;
tabState.tabGroupId = new Token(1L, 2L);
ThreadUtils.runOnUiThreadBlocking(
() -> {
TabTestUtils.restoreFieldsFromState(mTab, tabState);
});
assertEquals(tabState.timestampMillis, mTab.getTimestampMillis());
assertEquals(
tabState.lastNavigationCommittedTimestampMillis,
mTab.getLastNavigationCommittedTimestampMillis());
assertEquals(tabState.rootId, mTab.getRootId());
assertEquals(tabState.tabGroupId, mTab.getTabGroupId());
}
@FunctionalInterface
private interface TabCreator {
/** Create a new tab with the provided URL. */
Tab createTab(String url);
}
@Test
@SmallTest
@Feature({"Tab"})
public void testFreezeAndAppendPendingNavigation_AlreadyFrozen() {
String firstUrl =
sActivityTestRule.getTestServer().getURL("/chrome/test/data/android/about.html");
String secondUrl =
sActivityTestRule.getTestServer().getURL("/chrome/test/data/android/test.html");
checkFreezingAndAppendingPendingNavigation(
this::createSecondFrozenTab, firstUrl, secondUrl, "MyFrozenTitle");
}
@Test
@SmallTest
@Feature({"Tab"})
public void testFreezeAndAppendPendingNavigation_LiveBackground() {
String firstUrl =
sActivityTestRule.getTestServer().getURL("/chrome/test/data/android/about.html");
String secondUrl =
sActivityTestRule.getTestServer().getURL("/chrome/test/data/android/test.html");
checkFreezingAndAppendingPendingNavigation(
url -> {
Tab tab = sActivityTestRule.loadUrlInNewTab(url, /* incognito= */ false);
ThreadUtils.runOnUiThreadBlocking(
() -> {
TabModel model =
sActivityTestRule.getActivity().getCurrentTabModel();
TabModelUtils.setIndex(model, /* index= */ 0);
});
return tab;
},
firstUrl,
secondUrl,
"MyTitle");
}
@Test
@SmallTest
@Feature({"Tab"})
public void testFreezeAndAppendPendingNavigation_LiveBackground_NativePage() {
String firstUrl = UrlConstants.NTP_URL;
String secondUrl =
sActivityTestRule.getTestServer().getURL("/chrome/test/data/android/test.html");
checkFreezingAndAppendingPendingNavigation(
url -> {
Tab tab = sActivityTestRule.loadUrlInNewTab(url, /* incognito= */ false);
ThreadUtils.runOnUiThreadBlocking(
() -> {
TabModel model =
sActivityTestRule.getActivity().getCurrentTabModel();
TabModelUtils.setIndex(model, /* index= */ 0);
});
assertTrue(tab.isNativePage());
return tab;
},
firstUrl,
secondUrl,
"Not NTP");
}
@Test
@SmallTest
@Feature({"Tab"})
public void testFreezeAndAppendPendingNavigation_NullTitle() {
String firstUrl =
sActivityTestRule.getTestServer().getURL("/chrome/test/data/android/about.html");
String secondUrl =
sActivityTestRule.getTestServer().getURL("/chrome/test/data/android/test.html");
checkFreezingAndAppendingPendingNavigation(
this::createSecondFrozenTab, firstUrl, secondUrl, null);
}
private void checkFreezingAndAppendingPendingNavigation(
TabCreator tabCreator,
String firstUrl,
String secondUrl,
@Nullable String secondTitle) {
TabObserver observer = Mockito.mock(TabObserver.class);
Tab bgTab = tabCreator.createTab(firstUrl);
boolean wasFrozen = bgTab.isFrozen();
ThreadUtils.runOnUiThreadBlocking(
() -> {
bgTab.addObserver(observer);
bgTab.freezeAndAppendPendingNavigation(
new LoadUrlParams(secondUrl), secondTitle);
assertTrue(bgTab.isFrozen());
assertFalse(bgTab.isNativePage());
});
verify(observer).onUrlUpdated(eq(bgTab));
if (wasFrozen) {
verify(observer, never()).onContentChanged(bgTab);
} else {
verify(observer).onContentChanged(bgTab);
}
verify(observer).onFaviconUpdated(bgTab, null, null);
verify(observer).onTitleUpdated(bgTab);
verify(observer).onNavigationEntriesAppended(bgTab);
assertEquals(secondTitle, ChromeTabUtils.getTitleOnUiThread(bgTab));
assertEquals(secondUrl, ChromeTabUtils.getUrlStringOnUiThread(bgTab));
assertFalse(bgTab.isLoading());
assertNull(bgTab.getWebContents());
Runnable loadPage =
() -> {
ThreadUtils.runOnUiThreadBlocking(
() -> {
TabModel model =
sActivityTestRule.getActivity().getCurrentTabModel();
TabModelUtils.setIndex(model, model.indexOf(bgTab));
});
};
ChromeTabUtils.waitForTabPageLoaded(bgTab, secondUrl, loadPage);
assertNotNull(bgTab.getView());
ThreadUtils.runOnUiThreadBlocking(
() -> {
assertFalse(bgTab.canGoForward());
assertTrue(bgTab.canGoBack());
bgTab.goBack();
});
ChromeTabUtils.waitForTabPageLoaded(bgTab, firstUrl);
ThreadUtils.runOnUiThreadBlocking(
() -> {
assertFalse(bgTab.canGoBack());
assertTrue(bgTab.canGoForward());
});
}
private void detachOnUiThread(Tab tab) {
ThreadUtils.runOnUiThreadBlocking(
() -> {
WebContents webContents = tab.getWebContents();
if (webContents != null) webContents.setTopLevelNativeWindow(null);
tab.updateAttachment(/* window= */ null, /* tabDelegateFactory= */ null);
});
}
private void attachOnUiThread(Tab tab) {
ThreadUtils.runOnUiThreadBlocking(
() -> {
WindowAndroid window = sActivityTestRule.getActivity().getWindowAndroid();
WebContents webContents = tab.getWebContents();
if (webContents != null) webContents.setTopLevelNativeWindow(window);
tab.updateAttachment(window, /* tabDelegateFactory= */ null);
});
}
private Tab createSecondFrozenTab(String url) {
Tab tab = sActivityTestRule.loadUrlInNewTab(url, /* incognito= */ false);
return ThreadUtils.runOnUiThreadBlocking(
() -> {
TabState state = TabStateExtractor.from(tab);
sActivityTestRule
.getActivity()
.getCurrentTabModel()
.closeTabs(TabClosureParams.closeTab(tab).allowUndo(false).build());
return sActivityTestRule
.getActivity()
.getCurrentTabCreator()
.createFrozenTab(state, tab.getId(), /* index= */ 1);
});
}
}