// Copyright 2024 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.tab_group_sync;
import static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.MockitoJUnit;
import org.mockito.junit.MockitoRule;
import org.robolectric.annotation.Config;
import org.chromium.base.Token;
import org.chromium.base.test.BaseRobolectricTestRunner;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.tab.MockTab;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabCreationState;
import org.chromium.chrome.browser.tab.TabLaunchType;
import org.chromium.chrome.browser.tab_group_sync.TabGroupSyncController.TabCreationDelegate;
import org.chromium.chrome.browser.tasks.tab_groups.TabGroupModelFilter;
import org.chromium.chrome.test.util.browser.tabmodel.MockTabModel;
import org.chromium.components.tab_group_sync.ClosingSource;
import org.chromium.components.tab_group_sync.EventDetails;
import org.chromium.components.tab_group_sync.LocalTabGroupId;
import org.chromium.components.tab_group_sync.OpeningSource;
import org.chromium.components.tab_group_sync.SavedTabGroup;
import org.chromium.components.tab_group_sync.SavedTabGroupTab;
import org.chromium.components.tab_group_sync.TabGroupEvent;
import org.chromium.components.tab_group_sync.TabGroupSyncService;
import org.chromium.url.GURL;
import java.util.ArrayList;
import java.util.List;
/** Unit tests for the {@link LocalTabGroupMutationHelper}. */
@RunWith(BaseRobolectricTestRunner.class)
@Config(manifest = Config.NONE)
public class LocalTabGroupMutationHelperUnitTest {
private static final Token TOKEN_1 = new Token(2, 3);
private static final Token TOKEN_2 = new Token(4, 4);
private static final int TAB_ID_1 = 1;
private static final int TAB_ID_2 = 2;
private static final int ROOT_ID_1 = 1;
private static final int ROOT_ID_2 = 2;
private static final LocalTabGroupId LOCAL_TAB_GROUP_ID_1 = new LocalTabGroupId(TOKEN_1);
private static final LocalTabGroupId LOCAL_TAB_GROUP_ID_2 = new LocalTabGroupId(TOKEN_2);
private static final String TAB_TITLE_1 = "Tab Title 1";
private static final GURL TAB_URL_1 = new GURL("https://url1.com");
private static final GURL TAB_URL_2 = new GURL("https://url2.com");
private static final GURL UNSYNCABLE_URL_1 = new GURL("chrome://flags");
@Rule public MockitoRule mMockitoRule = MockitoJUnit.rule();
@Mock private Profile mProfile;
private MockTabModel mTabModel;
@Mock private TabGroupModelFilter mTabGroupModelFilter;
@Mock private TabGroupSyncService mTabGroupSyncService;
private NavigationTracker mNavigationTracker;
private LocalTabGroupMutationHelper mLocalMutationHelper;
private TestTabCreationDelegate mTabCreationDelegate;
private Tab mTab1;
private Tab mTab2;
private @Captor ArgumentCaptor<EventDetails> mEventDetailsCaptor;
@Before
public void setUp() {
mTabModel = spy(new MockTabModel(mProfile, null));
when(mTabGroupModelFilter.getTabModel()).thenReturn(mTabModel);
mNavigationTracker = new NavigationTracker();
mTabCreationDelegate = spy(new TestTabCreationDelegate());
mLocalMutationHelper =
new LocalTabGroupMutationHelper(
mTabGroupModelFilter,
mTabGroupSyncService,
mTabCreationDelegate,
mNavigationTracker);
when(mTabGroupModelFilter.getRootIdFromStableId(any())).thenReturn(Tab.INVALID_TAB_ID);
when(mTabGroupModelFilter.getRootIdFromStableId(eq(TOKEN_1))).thenReturn(ROOT_ID_1);
when(mTabGroupModelFilter.getStableIdFromRootId(eq(ROOT_ID_1))).thenReturn(TOKEN_1);
Mockito.doNothing()
.when(mTabGroupSyncService)
.recordTabGroupEvent(mEventDetailsCaptor.capture());
mTab1 = prepareTab(TAB_ID_1, ROOT_ID_1);
mTab2 = prepareTab(TAB_ID_2, ROOT_ID_2);
when(mTab1.getUrl()).thenReturn(TAB_URL_1);
when(mTab1.getTitle()).thenReturn(TAB_TITLE_1);
when(mTab2.getUrl()).thenReturn(TAB_URL_2);
}
@After
public void tearDown() {}
private void addOneTab() {
List<Tab> tabs = new ArrayList<>();
tabs.add(mTab1);
Mockito.doReturn(TOKEN_1).when(mTab1).getTabGroupId();
when(mTabGroupModelFilter.getRelatedTabListForRootId(eq(ROOT_ID_1))).thenReturn(tabs);
}
private Tab prepareTab(int tabId, int rootId) {
Tab tab = Mockito.mock(Tab.class);
Mockito.doReturn(tabId).when(tab).getId();
Mockito.doReturn(rootId).when(tab).getRootId();
Mockito.doReturn(tab).when(mTabModel).getTabById(tabId);
return tab;
}
private SavedTabGroup createOneSavedTabGroup(
LocalTabGroupId localTabGroupId, Integer[] tabIds) {
SavedTabGroup savedTabGroup = TabGroupSyncTestUtils.createSavedTabGroup();
savedTabGroup.localId = localTabGroupId;
for (int i = 0; i < tabIds.length; i++) {
savedTabGroup.savedTabs.get(i).localId = tabIds[i];
}
// The final group should match tabIds.
savedTabGroup.savedTabs.subList(tabIds.length, savedTabGroup.savedTabs.size()).clear();
Assert.assertEquals(savedTabGroup.savedTabs.size(), tabIds.length);
return savedTabGroup;
}
@Test
public void testCreateNewTabGroup() {
SavedTabGroup savedTabGroup = createOneSavedTabGroup(null, new Integer[] {null, null});
mLocalMutationHelper.createNewTabGroup(savedTabGroup, OpeningSource.AUTO_OPENED_FROM_SYNC);
// Verify calls to create local tab group, and update ID mappings for group and tabs.
verify(mTabGroupModelFilter).mergeListOfTabsToGroup(anyList(), any(), eq(false));
verify(mTabGroupModelFilter).setTabGroupColor(anyInt(), anyInt());
verify(mTabGroupModelFilter).setTabGroupTitle(anyInt(), any());
verify(mTabGroupModelFilter).setTabGroupCollapsed(anyInt(), eq(true));
verify(mTabGroupSyncService).updateLocalTabGroupMapping(any(), any());
verify(mTabGroupSyncService, times(2)).updateLocalTabId(any(), any(), anyInt());
// Verify metrics.
EventDetails eventDetails = mEventDetailsCaptor.getValue();
assertEquals(TabGroupEvent.TAB_GROUP_OPENED, eventDetails.eventType);
assertEquals(LOCAL_TAB_GROUP_ID_1, eventDetails.localGroupId);
assertEquals(OpeningSource.AUTO_OPENED_FROM_SYNC, eventDetails.openingSource);
}
@Test
public void testCreateNewTabGroup_SingleTab() {
SavedTabGroup savedTabGroup = createOneSavedTabGroup(null, new Integer[] {null});
mLocalMutationHelper.createNewTabGroup(savedTabGroup, OpeningSource.OPENED_FROM_REVISIT_UI);
// Verify calls to create local tab group, and update ID mappings for group and tabs.
verify(mTabGroupModelFilter).createSingleTabGroup(any(), eq(false));
verify(mTabGroupModelFilter).setTabGroupColor(anyInt(), anyInt());
verify(mTabGroupModelFilter).setTabGroupTitle(anyInt(), any());
verify(mTabGroupSyncService).updateLocalTabGroupMapping(any(), any());
verify(mTabGroupSyncService, times(1)).updateLocalTabId(any(), any(), anyInt());
}
@Test
public void testUpdateTabGroupUpdatesVisuals() {
addOneTab();
SavedTabGroup savedTabGroup =
createOneSavedTabGroup(LOCAL_TAB_GROUP_ID_1, new Integer[] {null, null});
savedTabGroup.title = "Updated group";
mLocalMutationHelper.updateTabGroup(savedTabGroup);
verify(mTabGroupModelFilter).setTabGroupTitle(eq(ROOT_ID_1), eq(savedTabGroup.title));
verify(mTabGroupModelFilter).setTabGroupColor(eq(ROOT_ID_1), anyInt());
}
@Test
public void testUpdateTabGroup_CloseLocalTabsThatDoNotExistInSync() {
// One local group with one tab syncing.
addOneTab();
// One saved group with two tabs: both with no local mapping.
SavedTabGroup savedTabGroup =
createOneSavedTabGroup(LOCAL_TAB_GROUP_ID_1, new Integer[] {null, null});
mLocalMutationHelper.updateTabGroup(savedTabGroup);
verify(mTabModel).closeTabs(argThat(params -> params.tabs.size() == 1));
}
@Test
public void testUpdateTabGroup_AddTabsFromSync() {
// One local group with one tab syncing.
addOneTab();
when(mTabGroupModelFilter.getTabGroupCollapsed(ROOT_ID_1)).thenReturn(true);
// One saved group with two tabs: both with no local mapping.
SavedTabGroup savedTabGroup =
createOneSavedTabGroup(LOCAL_TAB_GROUP_ID_1, new Integer[] {null, null});
mLocalMutationHelper.updateTabGroup(savedTabGroup);
// Collapsed must be re-set after the merge.
InOrder inOrder = inOrder(mTabGroupModelFilter, mTabModel, mTabGroupSyncService);
verify(mTabCreationDelegate, times(2))
.createBackgroundTab(any(), anyString(), any(), anyInt());
inOrder.verify(mTabGroupModelFilter, times(2))
.mergeListOfTabsToGroup(
anyList(), argThat(tab -> tab.getId() == ROOT_ID_1), eq(false));
verify(mTabGroupSyncService, times(1))
.updateLocalTabId(eq(LOCAL_TAB_GROUP_ID_1), any(), eq(TAB_ID_1));
inOrder.verify(mTabModel).closeTabs(argThat(params -> params.tabs.size() == 1));
inOrder.verify(mTabGroupModelFilter).setTabGroupCollapsed(ROOT_ID_1, true);
}
@Test
public void testUpdateTabGroup_UpdateExistingTab_Navigate() {
// One local group with one tab syncing.
addOneTab();
// One saved group with one tabs mapped to the local tab.
SavedTabGroup savedTabGroup =
createOneSavedTabGroup(LOCAL_TAB_GROUP_ID_1, new Integer[] {TAB_ID_1});
SavedTabGroupTab savedTab = savedTabGroup.savedTabs.get(0);
savedTab.url = TAB_URL_2;
savedTab.title = TAB_TITLE_1;
mLocalMutationHelper.updateTabGroup(savedTabGroup);
verify(mTabCreationDelegate, never())
.createBackgroundTab(any(), anyString(), any(), anyInt());
verify(mTabGroupModelFilter, never())
.mergeListOfTabsToGroup(anyList(), any(), anyBoolean());
verify(mTabGroupSyncService, never()).updateLocalTabId(any(), any(), anyInt());
verify(mTabModel, never()).closeTabs(any());
verify(mTabCreationDelegate, times(1))
.navigateToUrl(any(), eq(TAB_URL_2), eq(TAB_TITLE_1), eq(false));
}
@Test
public void testUpdateTabGroup_UpdateExistingTab_SkipNavigateSameUrl() {
// One local group with one tab syncing.
addOneTab();
// One saved group with one tabs mapped to the local tab. It has same URl as existing.
SavedTabGroup savedTabGroup =
createOneSavedTabGroup(LOCAL_TAB_GROUP_ID_1, new Integer[] {TAB_ID_1});
SavedTabGroupTab savedTab = savedTabGroup.savedTabs.get(0);
savedTab.url = TAB_URL_1;
mLocalMutationHelper.updateTabGroup(savedTabGroup);
verify(mTabCreationDelegate, never())
.createBackgroundTab(any(), anyString(), any(), anyInt());
verify(mTabCreationDelegate, never())
.navigateToUrl(any(), any(), anyString(), anyBoolean());
}
@Test
public void testUpdateTabGroup_UpdateExistingTab_UnsyncableUrlAreNotClobberedWithNTPUrl() {
// One local group with one tab syncing.
addOneTab();
when(mTab1.getUrl()).thenReturn(UNSYNCABLE_URL_1);
// One saved group with one tabs mapped to the local tab.
SavedTabGroup savedTabGroup =
createOneSavedTabGroup(LOCAL_TAB_GROUP_ID_1, new Integer[] {TAB_ID_1});
SavedTabGroupTab savedTab = savedTabGroup.savedTabs.get(0);
savedTab.url = TabGroupSyncUtils.UNSAVEABLE_URL_OVERRIDE;
mLocalMutationHelper.updateTabGroup(savedTabGroup);
verify(mTabCreationDelegate, never())
.navigateToUrl(any(), any(), anyString(), anyBoolean());
}
@Test
public void
testUpdateTabGroup_UpdateExistingTab_UnsyncableUrlAreOverwrittenWithValidNonNTPUrl() {
// One local group with one tab syncing.
addOneTab();
when(mTab1.getUrl()).thenReturn(UNSYNCABLE_URL_1);
// One saved group with one tabs mapped to the local tab.
SavedTabGroup savedTabGroup =
createOneSavedTabGroup(LOCAL_TAB_GROUP_ID_1, new Integer[] {TAB_ID_1});
SavedTabGroupTab savedTab = savedTabGroup.savedTabs.get(0);
savedTab.url = TAB_URL_2;
mLocalMutationHelper.updateTabGroup(savedTabGroup);
verify(mTabCreationDelegate, times(1))
.navigateToUrl(any(), eq(TAB_URL_2), anyString(), eq(false));
}
@Test
public void testUpdateTabGroup_UpdateExistingTabInWrongGroup() {
// One local group with one tab syncing.
addOneTab();
// One saved group with one tabs mapped to the tab but in wrong group.
SavedTabGroup savedTabGroup =
createOneSavedTabGroup(LOCAL_TAB_GROUP_ID_1, new Integer[] {TAB_ID_1});
when(mTab1.getRootId()).thenReturn(ROOT_ID_2);
mLocalMutationHelper.updateTabGroup(savedTabGroup);
verify(mTabCreationDelegate, times(1))
.createBackgroundTab(any(), anyString(), any(), anyInt());
verify(mTabGroupModelFilter, times(1))
.mergeListOfTabsToGroup(anyList(), any(), anyBoolean());
verify(mTabGroupSyncService, times(1))
.updateLocalTabId(eq(LOCAL_TAB_GROUP_ID_1), any(), eq(TAB_ID_1));
}
@Test
public void testCloseTabGroup() {
mTabModel.addTab(TAB_ID_1);
mLocalMutationHelper.closeTabGroup(LOCAL_TAB_GROUP_ID_1, ClosingSource.CLOSED_BY_USER);
verify(mTabModel).closeTabs(any());
verify(mTabGroupSyncService).removeLocalTabGroupMapping(eq(LOCAL_TAB_GROUP_ID_1));
EventDetails eventDetails = mEventDetailsCaptor.getValue();
assertEquals(TabGroupEvent.TAB_GROUP_CLOSED, eventDetails.eventType);
assertEquals(LOCAL_TAB_GROUP_ID_1, eventDetails.localGroupId);
assertEquals(ClosingSource.CLOSED_BY_USER, eventDetails.closingSource);
}
private class TestTabCreationDelegate implements TabCreationDelegate {
private int mNextTabId;
@Override
public Tab createBackgroundTab(GURL url, String title, Tab parent, int position) {
MockTab tab = new MockTab(++mNextTabId, mProfile);
tab.setIsInitialized(true);
tab.setUrl(url);
tab.setRootId(parent == null ? tab.getId() : parent.getRootId());
tab.setTitle("Tab Title");
mTabModel.addTab(
tab, -1, TabLaunchType.FROM_TAB_GROUP_UI, TabCreationState.LIVE_IN_BACKGROUND);
return tab;
}
@Override
public void navigateToUrl(Tab tab, GURL url, String title, boolean isForegroundTab) {}
}
}