chromium/chrome/test/data/webui/tab_strip/tab_list_test.ts

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

import 'chrome://tab-strip.top-chrome/tab_list.js';

import {FocusOutlineManager} from 'chrome://resources/js/focus_outline_manager.js';
import type {TabElement} from 'chrome://tab-strip.top-chrome/tab.js';
import type {TabGroupElement} from 'chrome://tab-strip.top-chrome/tab_group.js';
import type {TabListElement} from 'chrome://tab-strip.top-chrome/tab_list.js';
import {setScrollAnimationEnabledForTesting} from 'chrome://tab-strip.top-chrome/tab_list.js';
import type {PageRemote, Tab} from 'chrome://tab-strip.top-chrome/tab_strip.mojom-webui.js';
import {TabsApiProxyImpl} from 'chrome://tab-strip.top-chrome/tabs_api_proxy.js';
import {assertDeepEquals, assertEquals, assertFalse, assertTrue} from 'chrome://webui-test/chai_assert.js';

import {createTab, TestTabsApiProxy} from './test_tabs_api_proxy.js';

function flushTasks() {
  return new Promise(function(resolve) {
    window.setTimeout(resolve, 1);
  });
}

suite('TabList', () => {
  let tabList: TabListElement;
  let testTabsApiProxy: TestTabsApiProxy;
  let callbackRouter: PageRemote;

  const tabs: Tab[] = [
    createTab({
      active: true,
      id: 0,
      index: 0,
      title: 'Tab 1',
    }),
    createTab({
      id: 1,
      index: 1,
      title: 'Tab 2',
    }),
    createTab({
      active: false,
      id: 2,
      index: 2,
      title: 'Tab 3',
    }),
  ];

  function pinTabAt(tab: Tab, index: number) {
    const changeInfo = {index: index, pinned: true};
    const updatedTab = Object.assign({}, tab, changeInfo);
    callbackRouter.tabUpdated(updatedTab);
  }

  function unpinTabAt(tab: Tab, index: number) {
    const changeInfo = {index: index, pinned: false};
    const updatedTab = Object.assign({}, tab, changeInfo);
    callbackRouter.tabUpdated(updatedTab);
  }

  function getUnpinnedTabs(): NodeListOf<TabElement> {
    return tabList.shadowRoot!.querySelectorAll('#unpinnedTabs tabstrip-tab');
  }

  function getUnpinnedActiveTabs(): NodeListOf<TabElement> {
    return tabList.shadowRoot!.querySelectorAll(
        '#unpinnedTabs tabstrip-tab[active]');
  }

  function getPinnedTabs(): NodeListOf<TabElement> {
    return tabList.shadowRoot!.querySelectorAll('#pinnedTabs tabstrip-tab');
  }

  function getTabGroups(): NodeListOf<TabGroupElement> {
    return tabList.shadowRoot!.querySelectorAll('tabstrip-tab-group');
  }

  function waitFor(ms: number): Promise<void> {
    return new Promise<void>(resolve => {
      setTimeout(resolve, ms);
    });
  }

  function verifyTab(tab1: Tab, tab2: Tab) {
    assertEquals(tab1.active, tab2.active);
    assertDeepEquals(tab1.alertStates, tab2.alertStates);
    assertEquals(tab1.id, tab2.id);
    assertEquals(tab1.index, tab2.index);
    assertEquals(tab1.pinned, tab2.pinned);
    assertEquals(tab1.title, tab2.title);
    assertDeepEquals(tab1.url, tab2.url);
  }

  setup(async () => {
    document.documentElement.dir = 'ltr';
    document.body.innerHTML = window.trustedTypes!.emptyHTML;
    document.body.style.margin = '0';

    testTabsApiProxy = new TestTabsApiProxy();
    testTabsApiProxy.setTabs(tabs);
    TabsApiProxyImpl.setInstance(testTabsApiProxy);
    callbackRouter = testTabsApiProxy.getCallbackRouterRemote();

    testTabsApiProxy.setLayout({
      '--height': '100px',
      '--width': '150px',
    });
    testTabsApiProxy.setVisible(true);

    setScrollAnimationEnabledForTesting(false);

    tabList = document.createElement('tabstrip-tab-list');
    document.body.appendChild(tabList);

    await testTabsApiProxy.whenCalled('getTabs');
  });

  teardown(() => {
    testTabsApiProxy.reset();
  });

  test('sets layout variables on init', async () => {
    await testTabsApiProxy.whenCalled('getLayout');
    assertEquals(tabList.style.getPropertyValue('--height'), '100px');
    assertEquals(tabList.style.getPropertyValue('--width'), '150px');
  });

  test('updates layout variables when layout changes', async () => {
    callbackRouter.layoutChanged({
      '--height': '10000px',
      '--width': '10px',
    });
    await flushTasks();
    await testTabsApiProxy.whenCalled('getLayout');
    assertEquals(tabList.style.getPropertyValue('--height'), '10000px');
    assertEquals(tabList.style.getPropertyValue('--width'), '10px');
  });

  test('GroupVisualDataOnInit', async () => {
    testTabsApiProxy.reset();
    testTabsApiProxy.setTabs([createTab({
      active: true,
      groupId: 'group0',
      id: 0,
      index: 0,
      title: 'New tab',
    })]);
    testTabsApiProxy.setGroupVisualData({
      group0: {
        title: 'My group',
        color: 'rgba(255, 0, 0, 1)',
        textColor: 'black',
      },
    });
    // Remove and reinsert into DOM to retrigger connectedCallback();
    tabList.remove();
    document.body.appendChild(tabList);
    await testTabsApiProxy.whenCalled('getGroupVisualData');
  });

  test('GroupVisualDataOnThemeChange', async () => {
    testTabsApiProxy.reset();
    testTabsApiProxy.setGroupVisualData({
      group0: {
        title: 'My group',
        color: 'rgba(255, 0, 0, 1)',
        textColor: 'black',
      },
    });
    callbackRouter.themeChanged();
    await flushTasks();
    await testTabsApiProxy.whenCalled('getGroupVisualData');
  });

  test('calculates the correct unpinned tab width and height', async () => {
    callbackRouter.layoutChanged({
      '--tabstrip-tab-thumbnail-height': '132px',
      '--tabstrip-tab-thumbnail-width': '200px',
      '--tabstrip-tab-title-height': '15px',
    });
    await flushTasks();
    await testTabsApiProxy.whenCalled('getLayout');

    const tabListStyle = window.getComputedStyle(tabList);
    assertEquals(
        tabListStyle.getPropertyValue('--tabstrip-tab-height')
            .trim()
            .replace(/\n */, ' '),
        'calc(15px + 132px)');
    assertEquals(
        tabListStyle.getPropertyValue('--tabstrip-tab-width').trim(), '200px');
  });

  test('creates a tab element for each tab', () => {
    const tabElements = getUnpinnedTabs();
    assertEquals(tabs.length, tabElements.length);
    tabs.forEach((tab, index) => {
      assertEquals(tabElements[index]!.tab, tab);
    });
  });

  test(
      'adds a new tab element when a tab is added in same window', async () => {
        const appendedTab = createTab({
          active: false,
          id: 3,
          index: 3,
          title: 'New tab',
        });
        callbackRouter.tabCreated(appendedTab);
        await flushTasks();
        let tabElements = getUnpinnedTabs();
        assertEquals(tabs.length + 1, tabElements.length);
        verifyTab(tabElements[tabs.length]!.tab, appendedTab);

        const prependedTab = createTab({
          id: 4,
          index: 0,
          title: 'New tab',
        });
        callbackRouter.tabCreated(prependedTab);
        await flushTasks();
        tabElements = getUnpinnedTabs();
        assertEquals(tabs.length + 2, tabElements.length);
        verifyTab(tabElements[0]!.tab, prependedTab);
      });

  test('PlacesTabElement', () => {
    const pinnedTab = document.createElement('tabstrip-tab');
    tabList.placeTabElement(pinnedTab, 0, true, null);
    assertEquals(pinnedTab, getPinnedTabs()[0]);

    const unpinnedUngroupedTab = document.createElement('tabstrip-tab');
    tabList.placeTabElement(unpinnedUngroupedTab, 1, false, null);
    let unpinnedTabs = getUnpinnedTabs();
    assertEquals(4, unpinnedTabs.length);
    assertEquals(unpinnedUngroupedTab, unpinnedTabs[0]);

    const groupedTab = document.createElement('tabstrip-tab');
    tabList.placeTabElement(groupedTab, 1, false, 'group0');
    unpinnedTabs = getUnpinnedTabs();
    assertEquals(5, unpinnedTabs.length);
    assertEquals(groupedTab, unpinnedTabs[0]);
    assertEquals('TABSTRIP-TAB-GROUP', groupedTab.parentElement!.tagName);
  });

  function testPlaceElementAnimationParams(
      element: Element, horizontalScale: number, verticalScale: number) {
    const animations = element.getAnimations();
    assertEquals(1, animations.length);
    assertEquals('running', animations[0]!.playState);
    assertEquals(120, animations[0]!.effect!.getTiming().duration);
    assertEquals('ease-out', animations[0]!.effect!.getTiming().easing);

    const keyframes = (animations[0]!.effect as KeyframeEffect).getKeyframes();
    const horizontalTabSpacingVars =
        '(var(--tabstrip-tab-width) + var(--tabstrip-tab-spacing))';
    const verticalTabSpacingVars =
        '(var(--tabstrip-tab-height) + var(--tabstrip-tab-spacing))';
    assertEquals(2, keyframes.length);
    assertEquals(
        `translate(calc(${horizontalScale} * ${
            horizontalTabSpacingVars}), calc(${verticalScale} * ${
            verticalTabSpacingVars}))`,
        keyframes[0]!['transform']);
    assertEquals('translate(0px, 0px)', keyframes[1]!['transform']);
  }

  /**
   * This function should be called once per test since the animations finishing
   * and being included in the getAnimations() calls can cause flaky tests.
   * @param direction, the direction the moved tab should animate.
   *     +1 if moving right, -1 if moving left
   */
  async function testPlaceTabElementAnimation(
      indexToMove: number, newIndex: number, direction: number) {
    await tabList.animationPromises;
    const unpinnedTabs = getUnpinnedTabs();

    const movedTab = unpinnedTabs[indexToMove]!;
    tabList.placeTabElement(movedTab, newIndex, false, null);
    testPlaceElementAnimationParams(
        movedTab, -1 * direction * Math.abs(newIndex - indexToMove), 0);

    Array.from(unpinnedTabs)
        .filter(tabElement => tabElement !== movedTab)
        .forEach(
            tabElement =>
                testPlaceElementAnimationParams(tabElement, direction, 0));
  }

  test('PlaceTabElementAnimatesTabMovedTowardsStart', () => {
    return testPlaceTabElementAnimation(tabs.length - 1, 0, -1);
  });

  test('PlaceTabElementAnimatesTabMovedTowardsStartRTL', () => {
    document.documentElement.dir = 'rtl';
    return testPlaceTabElementAnimation(tabs.length - 1, 0, 1);
  });

  test('PlaceTabElementAnimatesTabMovedTowardsEnd', () => {
    return testPlaceTabElementAnimation(0, tabs.length - 1, 1);
  });

  test('PlaceTabElementAnimatesTabMovedTowardsEndRTL', () => {
    document.documentElement.dir = 'rtl';
    return testPlaceTabElementAnimation(0, tabs.length - 1, -1);
  });

  test('PlacePinnedTabElementAnimatesTabsWithinSameColumn', async () => {
    tabs.forEach(pinTabAt);
    await flushTasks();
    await tabList.animationPromises;

    // Test moving a tab within the same column. If a tab is moved from index 0
    // to index 2, it should move vertically down 2 places. Tabs at index 1 and
    // index 2 should move up 1 space.
    const pinnedTabs = getPinnedTabs();
    tabList.placeTabElement(pinnedTabs[0]!, 2, /*pinned=*/ true, null);
    await Promise.all([
      testPlaceElementAnimationParams(pinnedTabs[0]!, 0, -2),
      testPlaceElementAnimationParams(pinnedTabs[1]!, 0, 1),
      testPlaceElementAnimationParams(pinnedTabs[2]!, 0, 1),
    ]);
  });

  test(
      'PlacePinnedTabElementAnimatesTabsAcrossColumnsToHigherIndex',
      async () => {
        tabs.forEach(pinTabAt);
        for (let i = 0; i < 4; i++) {
          callbackRouter.tabCreated(createTab({
            id: tabs.length + i,
            index: tabs.length + i,
            pinned: true,
            title: 'Pinned tab',
          }));
        }
        await flushTasks();
        await tabList.animationPromises;

        const pinnedTabs = getPinnedTabs();
        tabList.placeTabElement(pinnedTabs[2]!, 6, /*pinned=*/ true, null);
        await Promise.all([
          testPlaceElementAnimationParams(pinnedTabs[2]!, -2, 2),
          testPlaceElementAnimationParams(pinnedTabs[3]!, 1, -2),
          testPlaceElementAnimationParams(pinnedTabs[4]!, 0, 1),
          testPlaceElementAnimationParams(pinnedTabs[5]!, 0, 1),
          testPlaceElementAnimationParams(pinnedTabs[6]!, 1, -2),
        ]);
      });

  test(
      'PlacePinnedTabElementAnimatesTabsAcrossColumnsToLowerIndex',
      async () => {
        tabs.forEach(pinTabAt);
        for (let i = 0; i < 4; i++) {
          callbackRouter.tabCreated(createTab({
            id: tabs.length + i,
            index: tabs.length + i,
            pinned: true,
            title: 'Pinned tab',
          }));
        }
        await flushTasks();
        await tabList.animationPromises;

        const pinnedTabs = getPinnedTabs();
        tabList.placeTabElement(pinnedTabs[3]!, 0, /*pinned=*/ true, null);
        await Promise.all([
          testPlaceElementAnimationParams(pinnedTabs[3]!, 1, 0),
          testPlaceElementAnimationParams(pinnedTabs[2]!, -1, 2),
          testPlaceElementAnimationParams(pinnedTabs[1]!, 0, -1),
          testPlaceElementAnimationParams(pinnedTabs[0]!, 0, -1),
        ]);
      });

  test('PlacesTabGroupElement', () => {
    const tabGroupElement = document.createElement('tabstrip-tab-group');
    tabList.placeTabGroupElement(tabGroupElement, 2);

    const tabGroupElements = getTabGroups();
    assertEquals(1, tabGroupElements.length);
    assertEquals(tabGroupElement, tabGroupElements[0]);

    // Group was inserted at index 2, so it should come after the 2nd tab.
    assertEquals(getUnpinnedTabs()[1], tabGroupElement.previousElementSibling);
  });

  async function testPlaceTabGroupElementAnimation(
      indexToGroup: number, newIndex: number, direction: number) {
    await tabList.animationPromises;

    // Group the tab at indexToGroup.
    const unpinnedTabs = getUnpinnedTabs();
    const tabToGroup = unpinnedTabs[indexToGroup]!;
    callbackRouter.tabGroupStateChanged(
        tabToGroup.tab.id, indexToGroup, 'group0');
    await flushTasks();

    const groupElement = tabToGroup.parentElement as TabGroupElement;
    tabList.placeTabGroupElement(groupElement, newIndex);
    testPlaceElementAnimationParams(
        groupElement, -1 * direction * Math.abs(newIndex - indexToGroup), 0);

    // Test animations on all the other tabs.
    Array.from(getUnpinnedTabs())
        .filter(tabElement => tabElement.parentElement !== groupElement)
        .forEach(
            tabElement =>
                testPlaceElementAnimationParams(tabElement, direction, 0));
  }

  test('PlaceTabGroupElementAnimatesTabGroupMovedTowardsStart', () => {
    return testPlaceTabGroupElementAnimation(tabs.length - 1, 0, -1);
  });

  test('PlaceTabGroupElementAnimatesTabGroupMovedTowardsStartRTL', () => {
    document.documentElement.dir = 'rtl';
    return testPlaceTabGroupElementAnimation(tabs.length - 1, 0, 1);
  });

  test('PlaceTabGroupElementAnimatesTabGroupMovedTowardsEnd', () => {
    return testPlaceTabGroupElementAnimation(0, tabs.length - 1, 1);
  });

  test('PlaceTabGroupElementAnimatesTabGroupMovedTowardsEndRTL', () => {
    document.documentElement.dir = 'rtl';
    return testPlaceTabGroupElementAnimation(0, tabs.length - 1, -1);
  });

  test('PlaceTabGroupElementAnimationWithMultipleTabs', async () => {
    await tabList.animationPromises;

    // Group all tabs except for the first one.
    const ungroupedTab = getUnpinnedTabs()[0]!;
    tabs.slice(1).forEach(tab => {
      callbackRouter.tabGroupStateChanged(tab.id, tab.index, 'group0');
    });
    await flushTasks();

    // Move the group to index 0.
    const tabGroup = getTabGroups()[0]!;
    tabList.placeTabGroupElement(tabGroup, 0);

    // Both the TabElement and TabGroupElement should move by a scale of 1.
    testPlaceElementAnimationParams(tabGroup, 1, 0);
    testPlaceElementAnimationParams(ungroupedTab, -1, 0);
  });

  test('AddNewTabGroup', async () => {
    const appendedTab = createTab({
      groupId: 'group0',
      id: 3,
      index: 3,
      title: 'New tab in group',
    });
    callbackRouter.tabCreated(appendedTab);
    await flushTasks();
    let tabElements = getUnpinnedTabs();
    assertEquals(tabs.length + 1, tabElements.length);
    assertEquals(getTabGroups().length, 1);
    assertEquals(
        'TABSTRIP-TAB-GROUP',
        tabElements[appendedTab.index]!.parentElement!.tagName);

    const prependedTab = createTab({
      groupId: 'group1',
      id: 4,
      index: 0,
      title: 'New tab',
    });
    callbackRouter.tabCreated(prependedTab);
    await flushTasks();
    tabElements = getUnpinnedTabs();
    assertEquals(tabs.length + 2, tabElements.length);
    assertEquals(getTabGroups().length, 2);
    assertEquals(
        'TABSTRIP-TAB-GROUP',
        tabElements[prependedTab.index]!.parentElement!.tagName);
  });

  test('AddTabToExistingGroup', async () => {
    const appendedTab = createTab({
      groupId: 'group0',
      id: 3,
      index: 3,
      title: 'New tab in group',
    });
    callbackRouter.tabCreated(appendedTab);
    await flushTasks();
    const appendedTabInSameGroup = createTab({
      groupId: 'group0',
      id: 4,
      index: 4,
      title: 'New tab in same group',
    });
    callbackRouter.tabCreated(appendedTabInSameGroup);
    await flushTasks();
    const tabGroups = getTabGroups();
    assertEquals(tabGroups.length, 1);
    const children = tabGroups[0]!.children as HTMLCollectionOf<TabElement>;
    assertEquals(children.item(0)!.tab.id, appendedTab.id);
    assertEquals(children.item(1)!.tab.id, appendedTabInSameGroup.id);
  });

  // Test that the TabList does not add a non-grouped tab to a tab group at the
  // same index.
  test('HandleSingleTabBeforeGroup', async () => {
    const tabInGroup = createTab({
      groupId: 'group0',
      id: 3,
      index: 3,
      title: 'New tab in group',
    });
    callbackRouter.tabCreated(tabInGroup);
    await flushTasks();
    const tabNotInGroup = createTab({
      id: 4,
      index: 3,
      title: 'New tab not in group',
    });
    callbackRouter.tabCreated(tabNotInGroup);
    await flushTasks();
    const tabsContainerChildren =
        tabList.shadowRoot!.querySelector('#unpinnedTabs')!.children;
    assertEquals(tabsContainerChildren.item(3)!.tagName, 'TABSTRIP-TAB');
    verifyTab((tabsContainerChildren.item(3) as TabElement).tab, tabNotInGroup);
    assertEquals(tabsContainerChildren.item(4)!.tagName, 'TABSTRIP-TAB-GROUP');
  });

  test('HandleGroupedTabBeforeDifferentGroup', async () => {
    const tabInOriginalGroup = tabs[1]!;
    callbackRouter.tabGroupStateChanged(
        tabInOriginalGroup.id, tabInOriginalGroup.index, 'originalGroup');

    // Create another group from the tab before group A.
    const tabInPrecedingGroup = tabs[0]!;
    callbackRouter.tabGroupStateChanged(
        tabInPrecedingGroup.id, tabInPrecedingGroup.index, 'precedingGroup');
    await flushTasks();
    const tabsContainerChildren =
        tabList.shadowRoot!.querySelector('#unpinnedTabs')!.children;

    const precedingGroup = tabsContainerChildren[0] as HTMLElement;
    assertEquals(precedingGroup.tagName, 'TABSTRIP-TAB-GROUP');
    assertEquals(precedingGroup.dataset['groupId'], 'precedingGroup');
    assertEquals(precedingGroup.children.length, 1);
    assertEquals(
        (precedingGroup.children[0] as TabElement).tab.id,
        tabInPrecedingGroup.id);

    const originalGroup = tabsContainerChildren[1] as HTMLElement;
    assertEquals(originalGroup.tagName, 'TABSTRIP-TAB-GROUP');
    assertEquals(originalGroup.dataset['groupId'], 'originalGroup');
    assertEquals(originalGroup.children.length, 1);
    assertEquals(
        (originalGroup.children[0] as TabElement).tab.id,
        tabInOriginalGroup.id);
  });

  test('HandleGroupedTabBeforeSameGroup', async () => {
    const originalTabInGroup = tabs[1]!;
    callbackRouter.tabGroupStateChanged(
        originalTabInGroup.id, originalTabInGroup.index, 'sameGroup');

    // Create another group from the tab before group A.
    const precedingTabInGroup = tabs[0]!;
    callbackRouter.tabGroupStateChanged(
        precedingTabInGroup.id, precedingTabInGroup.index, 'sameGroup');
    await flushTasks();

    const tabGroups = getTabGroups();
    const tabGroup = tabGroups[0]!;
    assertEquals(tabGroups.length, 1);
    assertEquals(tabGroup.dataset['groupId'], 'sameGroup');
    assertEquals(tabGroup.children.length, 2);
    assertEquals(
        (tabGroup.children[0] as TabElement).tab.id, precedingTabInGroup.id);
    assertEquals(
        (tabGroup.children[1] as TabElement).tab.id, originalTabInGroup.id);
  });

  test('removes a tab when tab is removed from current window', async () => {
    const tabToRemove = tabs[0]!;
    callbackRouter.tabRemoved(tabToRemove.id);
    await flushTasks();
    await tabList.animationPromises;
    assertEquals(tabs.length - 1, getUnpinnedTabs().length);
  });

  test('updates a tab with new tab data when a tab is updated', async () => {
    const tabToUpdate = tabs[0];
    const changeInfo = {title: 'A new title'};
    const updatedTab = Object.assign({}, tabToUpdate, changeInfo);
    callbackRouter.tabUpdated(updatedTab);
    await flushTasks();
    const tabElements = getUnpinnedTabs();
    verifyTab(tabElements[0]!.tab, updatedTab);
  });

  test('updates tabs when a new tab is activated', async () => {
    const tabElements = getUnpinnedTabs();

    // Mock activating the 2nd tab
    callbackRouter.tabActiveChanged(tabs[1]!.id);
    await flushTasks();
    assertFalse(tabElements[0]!.tab.active);
    assertTrue(tabElements[1]!.tab.active);
    assertFalse(tabElements[2]!.tab.active);
  });

  test('SingleActiveTabOnActiveTabCreated', async () => {
    let activeTabElements = getUnpinnedActiveTabs();
    assertEquals(activeTabElements.length, 1);

    const newActiveTab = createTab({
      active: true,
      id: tabs.length,
      index: tabs.length - 1,
      title: 'Tab 4',
    });
    callbackRouter.tabCreated(newActiveTab);
    await flushTasks();

    activeTabElements = getUnpinnedActiveTabs();
    assertEquals(activeTabElements.length, 1);
    assertEquals(activeTabElements[0]!.tab.id, newActiveTab.id);
  });

  test('adds a pinned tab to its designated container', async () => {
    callbackRouter.tabCreated(createTab({
      id: tabs.length,
      index: 0,
      title: 'New pinned tab',
      pinned: true,
    }));
    await flushTasks();
    const pinnedTabElements = getPinnedTabs();
    assertEquals(pinnedTabElements.length, 1);
    assertTrue(pinnedTabElements[0]!.tab.pinned);
  });

  test('moves pinned tabs to designated containers', async () => {
    const tabToPin = tabs[1]!;
    const changeInfo = {index: 0, pinned: true};
    let updatedTab = Object.assign({}, tabToPin, changeInfo);
    callbackRouter.tabUpdated(updatedTab);
    await flushTasks();

    const pinnedTabElements = getPinnedTabs();
    assertEquals(pinnedTabElements.length, 1);
    assertTrue(pinnedTabElements[0]!.tab.pinned);
    assertEquals(pinnedTabElements[0]!.tab.id, tabToPin.id);
    assertEquals(getUnpinnedTabs().length, 2);

    // Unpin the tab so that it's now at index 0
    changeInfo.index = 0;
    changeInfo.pinned = false;
    updatedTab = Object.assign({}, updatedTab, changeInfo);
    callbackRouter.tabUpdated(updatedTab);
    await flushTasks();

    const unpinnedTabElements = getUnpinnedTabs();
    assertEquals(getPinnedTabs().length, 0);
    assertEquals(unpinnedTabElements.length, 3);
    assertEquals(unpinnedTabElements[0]!.tab.id, tabToPin.id);
  });

  test('moves tab elements when tabs move', async () => {
    const tabElementsBeforeMove = getUnpinnedTabs();
    const tabToMove = tabs[0]!;
    callbackRouter.tabMoved(tabToMove.id, 2, false);
    await flushTasks();

    const tabElementsAfterMove = getUnpinnedTabs();
    assertEquals(tabElementsBeforeMove[0], tabElementsAfterMove[2]);
    assertEquals(tabElementsBeforeMove[1], tabElementsAfterMove[0]);
    assertEquals(tabElementsBeforeMove[2], tabElementsAfterMove[1]);
  });

  test('MoveExistingTabToGroup', async () => {
    const tabToGroup = tabs[1]!;
    callbackRouter.tabGroupStateChanged(
        tabToGroup.id, tabToGroup.index, 'group0');
    await flushTasks();
    let tabElements = getUnpinnedTabs();
    assertEquals(tabElements.length, tabs.length);
    assertEquals(
        tabElements[tabToGroup.index]!.parentElement!.tagName,
        'TABSTRIP-TAB-GROUP');

    const anotherTabToGroup = tabs[2]!;
    callbackRouter.tabGroupStateChanged(
        anotherTabToGroup.id, anotherTabToGroup.index, 'group0');
    await flushTasks();
    tabElements = getUnpinnedTabs();
    assertEquals(tabElements.length, tabs.length);
    assertEquals(
        tabElements[tabToGroup.index]!.parentElement,
        tabElements[anotherTabToGroup.index]!.parentElement);
  });

  test('MoveTabGroup', async () => {
    const tabToGroup = tabs[1]!;
    callbackRouter.tabGroupStateChanged(
        tabToGroup.id, tabToGroup.index, 'group0');
    callbackRouter.tabMoved(tabToGroup.id, 0, false);
    callbackRouter.tabGroupMoved('group0', 0);
    await flushTasks();

    const tabAtIndex0 = getUnpinnedTabs()[0]!;
    assertEquals(tabAtIndex0.parentElement!.tagName, 'TABSTRIP-TAB-GROUP');
    assertEquals(tabAtIndex0.tab.id, tabToGroup.id);
  });

  test('MoveTabGroupMultipleTabs', async () => {
    const tabToGroup1 = tabs[1]!;
    const tabToGroup2 = tabs[2]!;

    // Group tabs {1, 2} and assert the tab elements are correctly added under
    // their tab group element.
    callbackRouter.tabGroupStateChanged(
        tabToGroup1.id, tabToGroup1.index, 'group0');
    callbackRouter.tabGroupStateChanged(
        tabToGroup2.id, tabToGroup2.index, 'group0');
    await flushTasks();
    assertEquals(
        getUnpinnedTabs()[1]!.parentElement!.tagName, 'TABSTRIP-TAB-GROUP');
    assertEquals(
        getUnpinnedTabs()[2]!.parentElement!.tagName, 'TABSTRIP-TAB-GROUP');

    // During a drag and drop session that triggers a tab group move within the
    // WebUI tab strip the following sequence of events occur:
    //   1. The drag manager places the existing tab group element at the
    //      proposed drop index.
    //   2. The drag completes and the tab strip model is updated. This results
    //      in a series of tabMoved() events followed by a final tabGroupMoved()
    //      event.
    // The code below simulates this sequence of events and ensures the tab
    // strip responds correctly.

    // 1.
    const tabGroupElement = tabList.shadowRoot!.querySelector(
        'tabstrip-tab-group[data-group-id="group0"]')!;
    tabList.placeTabGroupElement(tabGroupElement as TabGroupElement, 0);

    // 2.
    callbackRouter.tabMoved(tabToGroup2.id, 0, false);
    callbackRouter.tabMoved(tabToGroup1.id, 0, false);
    callbackRouter.tabGroupMoved('group0', 0);
    await flushTasks();

    // Assert the tabs have moved as expected and are still members of their
    // oroginal tab group colloring the move.
    const tabAtIndex0 = getUnpinnedTabs()[0]!;
    assertEquals(tabAtIndex0.parentElement!.tagName, 'TABSTRIP-TAB-GROUP');
    assertEquals(tabAtIndex0.tab.id, tabToGroup1.id);

    const tabAtIndex1 = getUnpinnedTabs()[1]!;
    assertEquals(tabAtIndex1.parentElement!.tagName, 'TABSTRIP-TAB-GROUP');
    assertEquals(tabAtIndex1.tab.id, tabToGroup2.id);
  });

  test('tracks and untracks thumbnails based on viewport', async () => {
    // Wait for slideIn animations to complete updating widths and reset
    // resolvers to track new calls.
    await tabList.animationPromises;
    await testTabsApiProxy.whenCalled('setThumbnailTracked');
    testTabsApiProxy.reset();
    const tabElements = getUnpinnedTabs();

    // Update width such that at most one tab can fit in the viewport at once.
    tabList.style.setProperty('--tabstrip-tab-width', `${window.innerWidth}px`);

    // At this point, the only visible tab should be the first tab. The second
    // tab should fit within the rootMargin of the IntersectionObserver. The
    // third tab should not be intersecting.
    let [tabId, thumbnailTracked] =
        await testTabsApiProxy.whenCalled('setThumbnailTracked');
    assertEquals(tabId, tabElements[2]!.tab.id);
    assertEquals(thumbnailTracked, false);
    assertEquals(testTabsApiProxy.getCallCount('setThumbnailTracked'), 1);
    testTabsApiProxy.reset();

    // Scroll such that the second tab is now the only visible tab. At this
    // point, all 3 tabs should fit within the root and rootMargin of the
    // IntersectionObserver. Since the 3rd tab was not being tracked before,
    // it should be the only tab to become tracked.
    tabList.scrollLeft = tabElements[1]!.offsetLeft;
    [tabId, thumbnailTracked] =
        await testTabsApiProxy.whenCalled('setThumbnailTracked');
    assertEquals(tabId, tabElements[2]!.tab.id);
    assertEquals(thumbnailTracked, true);
    assertEquals(testTabsApiProxy.getCallCount('setThumbnailTracked'), 1);
    testTabsApiProxy.reset();

    // Scroll such that the third tab is now the only visible tab. At this
    // point, the first tab should be outside of the rootMargin of the
    // IntersectionObserver.
    tabList.scrollLeft = tabElements[2]!.offsetLeft;
    [tabId, thumbnailTracked] =
        await testTabsApiProxy.whenCalled('setThumbnailTracked');
    assertEquals(tabId, tabElements[0]!.tab.id);
    assertEquals(thumbnailTracked, false);
    assertEquals(testTabsApiProxy.getCallCount('setThumbnailTracked'), 1);
  });

  test('tracks and untracks thumbnails based on pinned state', async () => {
    await tabList.animationPromises;
    await testTabsApiProxy.whenCalled('setThumbnailTracked');
    testTabsApiProxy.reset();

    // Remove all other tabs to isolate the tab to test, and then wait for
    // each tab to get untracked as it is removed from the DOM.
    const tabElements = getUnpinnedTabs();
    tabElements[0]!.remove();
    await testTabsApiProxy.whenCalled('setThumbnailTracked');
    testTabsApiProxy.reset();
    tabElements[1]!.remove();
    await testTabsApiProxy.whenCalled('setThumbnailTracked');
    testTabsApiProxy.reset();

    // Pinning the third tab should untrack thumbnails for the tab
    pinTabAt(tabs[2]!, 0);
    let [tabId, thumbnailTracked] =
        await testTabsApiProxy.whenCalled('setThumbnailTracked');
    assertEquals(tabId, tabs[2]!.id);
    assertEquals(thumbnailTracked, false);
    testTabsApiProxy.reset();

    // Unpinning the tab should re-track the thumbnails
    unpinTabAt(tabs[2]!, 0);
    [tabId, thumbnailTracked] =
        await testTabsApiProxy.whenCalled('setThumbnailTracked');

    assertEquals(tabId, tabs[2]!.id);
    assertEquals(thumbnailTracked, true);
  });

  test('should update thumbnail track status on visibilitychange', async () => {
    await tabList.animationPromises;
    await testTabsApiProxy.whenCalled('setThumbnailTracked');
    testTabsApiProxy.reset();

    testTabsApiProxy.setVisible(false);
    document.dispatchEvent(new Event('visibilitychange'));

    // The tab strip should force untrack thumbnails for all tabs.
    await testTabsApiProxy.whenCalled('setThumbnailTracked');
    assertEquals(
        testTabsApiProxy.getCallCount('setThumbnailTracked'), tabs.length);
    testTabsApiProxy.reset();

    // Update width such that at all tabs can fit
    tabList.style.setProperty(
        '--tabstrip-tab-width', `${window.innerWidth / tabs.length}px`);

    testTabsApiProxy.setVisible(true);
    document.dispatchEvent(new Event('visibilitychange'));

    await testTabsApiProxy.whenCalled('setThumbnailTracked');
    assertEquals(
        testTabsApiProxy.getCallCount('setThumbnailTracked'), tabs.length);
  });

  // Flaky on all platforms. https://crbug.com/1247687.
  test.skip('ShouldDebounceThumbnailTrackerWhenScrollingFast', async () => {
    // Set tab widths such that 3 tabs fit in the viewport. This should reach a
    // state where the first 6 thumbnails are being tracked: 3 in the viewport
    // and 3 within the IntersectionObserver's rootMargin. The widths need to be
    // full integers to avoid rounding errors.
    const tabsPerViewport = 3;
    const tabStripWidth = window.innerWidth - window.innerWidth % 3;
    tabList.style.width = `${tabStripWidth}px`;
    tabList.style.setProperty(
        '--tabstrip-tab-width', `${tabStripWidth / tabsPerViewport}px`);
    tabList.style.setProperty('--tabstrip-tab-height', '10px');
    tabList.style.setProperty('--tabstrip-tab-spacing', '0px');

    await tabList.animationPromises;
    await testTabsApiProxy.whenCalled('setThumbnailTracked');
    testTabsApiProxy.reset();

    // Add enough tabs for there to be 13 tabs.
    for (let i = 0; i < 10; i++) {
      callbackRouter.tabCreated(createTab({
        id: tabs.length + i,
        index: tabs.length + i,
        title: `Tab ${tabs.length + i + 1}`,
      }));
    }
    await flushTasks();
    await tabList.animationPromises;
    await testTabsApiProxy.whenCalled('setThumbnailTracked');
    testTabsApiProxy.reset();
    testTabsApiProxy.resetThumbnailRequestCounts();

    // Mock 3 scroll events and end up with a scrolled state where the 10th
    // tab is aligned to the left. This should only evaluate to 1 set of
    // thumbnail updates and should most importantly skip the 6th tab.
    const tabElements = getUnpinnedTabs();
    tabList.scrollLeft = tabElements[3]!.offsetLeft;
    tabList.scrollLeft = tabElements[5]!.offsetLeft;
    tabList.scrollLeft = tabElements[10]!.offsetLeft;
    assertEquals(0, testTabsApiProxy.getCallCount('setThumbnailTracked'));

    await waitFor(200);
    assertEquals(12, testTabsApiProxy.getCallCount('setThumbnailTracked'));
    assertEquals(0, testTabsApiProxy.getThumbnailRequestCount(6));
    for (let tabId = 7; tabId < 13; tabId++) {
      assertEquals(1, testTabsApiProxy.getThumbnailRequestCount(tabId));
    }
  });

  test(
      'focusing on tab strip with the keyboard adds a class and focuses ' +
          'the first tab',
      async () => {
        callbackRouter.receivedKeyboardFocus();
        await flushTasks();
        assertEquals(document.activeElement, tabList);
        assertEquals(tabList.shadowRoot!.activeElement, getUnpinnedTabs()[0]);
        assertTrue(FocusOutlineManager.forDocument(document).visible);
      });

  test('blurring the tab strip blurs the active element', async () => {
    // First, make sure tab strip has keyboard focus.
    callbackRouter.receivedKeyboardFocus();
    await flushTasks();

    window.dispatchEvent(new Event('blur'));
    assertEquals(tabList.shadowRoot!.activeElement, null);
  });

  test('should update the ID when a tab is replaced', async () => {
    assertEquals(getUnpinnedTabs()[0]!.tab.id, 0);
    callbackRouter.tabReplaced(tabs[0]!.id, 1000);
    await flushTasks();
    assertEquals(getUnpinnedTabs()[0]!.tab.id, 1000);
  });

  test('has custom context menu', async () => {
    const event =
        new PointerEvent('pointerup', {clientX: 1, clientY: 2, button: 2});
    document.dispatchEvent(event);

    const contextMenuArgs =
        await testTabsApiProxy.whenCalled('showBackgroundContextMenu');
    assertEquals(contextMenuArgs[0], 1);
    assertEquals(contextMenuArgs[1], 2);
  });

  test('scrolls to active tabs', async () => {
    await tabList.animationPromises;

    const scrollPadding = 32;
    const tabWidth = 200;
    const viewportWidth = 300;

    // Mock the width of each tab element.
    tabList.style.setProperty(
        '--tabstrip-tab-thumbnail-width', `${tabWidth}px`);
    tabList.style.setProperty('--tabstrip-tab-spacing', '0px');
    const tabElements = getUnpinnedTabs();
    tabElements.forEach(tabElement => {
      tabElement.style.width = `${tabWidth}px`;
    });

    // Mock the scroller size such that it cannot fit only 1 tab at a time.
    tabList.style.setProperty(
        '--tabstrip-viewport-width', `${viewportWidth}px`);
    tabList.style.width = `${viewportWidth}px`;

    // Verify the scrollLeft is currently at its default state of 0, and then
    // send a visibilitychange event to cause a scroll.
    assertEquals(tabList.scrollLeft, 0);
    callbackRouter.tabActiveChanged(tabs[1]!.id);
    await flushTasks();
    testTabsApiProxy.setVisible(false);
    document.dispatchEvent(new Event('visibilitychange'));

    // The 2nd tab should be off-screen to the right, so activating it should
    // scroll so that the element's right edge is aligned with the screen's
    // right edge.
    const activeTab = getUnpinnedTabs()[1]!;
    assertEquals(
        tabList.scrollLeft + tabList.offsetWidth,
        activeTab.offsetLeft + activeTab.offsetWidth + scrollPadding);

    // The 1st tab should be now off-screen to the left, so activating it should
    // scroll so that the element's left edge is aligned with the screen's
    // left edge.
    callbackRouter.tabActiveChanged(tabs[0]!.id);
    await flushTasks();
    assertEquals(tabList.scrollLeft, 0);
  });

  test('PreventsDraggingWhenOnlyOneTab', () => {
    assertFalse(tabList.shouldPreventDrag(/*isDraggingTab=*/ true));
    const tabElements = getUnpinnedTabs();
    tabElements[1]!.remove();
    tabElements[2]!.remove();
    assertTrue(tabList.shouldPreventDrag(/*isDraggingTab=*/ true));
  });

  test('PreventsDraggingWhenOnlyOneTabGroup', async () => {
    // Create a tab group with 1 tab.
    const appendedTab = createTab({
      groupId: 'group0',
      id: 3,
      index: 3,
      title: 'New tab in group',
    });
    callbackRouter.tabCreated(appendedTab);
    await flushTasks();

    // Remove all tabs outside the tab group.
    const tabElements = getUnpinnedTabs();
    tabElements[0]!.remove();
    tabElements[1]!.remove();
    tabElements[2]!.remove();

    // At this point there's only 1 tab in 1 tab group.

    // Dragging a tab is not allowed.
    assertTrue(tabList.shouldPreventDrag(/*isDraggingTab=*/ true));

    // Dragging a tab group is not allowed.
    assertTrue(tabList.shouldPreventDrag(/*isDraggingTab=*/ false));

    // Add another tab in the same tab group.
    const appendedTabInSameGroup = createTab({
      groupId: 'group0',
      id: 4,
      index: 4,
      title: 'New tab in same group',
    });
    callbackRouter.tabCreated(appendedTabInSameGroup);
    await flushTasks();

    // At this point there are 2 tabs in 1 tab group;

    // Dragging a tab is still allowed.
    assertFalse(tabList.shouldPreventDrag(/*isDraggingTab=*/ true));

    // Dragging a tab group is not allowed.
    assertTrue(tabList.shouldPreventDrag(/*isDraggingTab=*/ false));
  });
});