chromium/chrome/test/data/webui/tab_strip/tab_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.js';

import {getFavicon} from 'chrome://resources/js/icon.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import type {TabElement} from 'chrome://tab-strip.top-chrome/tab.js';
import type {Tab} from 'chrome://tab-strip.top-chrome/tab_strip.mojom-webui.js';
import {TabNetworkState} from 'chrome://tab-strip.top-chrome/tab_strip.mojom-webui.js';
import {CloseTabAction, TabsApiProxyImpl} from 'chrome://tab-strip.top-chrome/tabs_api_proxy.js';
import {assertEquals, assertFalse, assertTrue} from 'chrome://webui-test/chai_assert.js';

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

suite('Tab', function() {
  let testTabsApiProxy: TestTabsApiProxy;
  let tabElement: TabElement;

  function queryTab(): HTMLElement {
    return tabElement.shadowRoot!.querySelector<HTMLElement>('#tab')!;
  }

  const tab: Tab = createTab({
    index: 0,
    showIcon: true,
    title: 'My Title',
  });

  /**
   * Convenience function for creating a typed Tab object.
   */
  function createTabData(overrides?: Partial<Tab>): Tab {
    return Object.assign({}, tab, overrides);
  }

  const strings = {
    closeTab: 'Close tab',
    loadingTab: 'Loading...',
    tabCrashed: '$1 has crashed',
    tabNetworkError: '$1 has a network error',
  };

  setup(() => {
    loadTimeData.overrideValues(strings);

    document.body.innerHTML = window.trustedTypes!.emptyHTML;

    // Set CSS variable for animations
    document.body.style.setProperty('--tabstrip-tab-height', '100px');
    document.body.style.setProperty('--tabstrip-tab-width', '280px');
    document.body.style.setProperty('--tabstrip-tab-spacing', '20px');

    testTabsApiProxy = new TestTabsApiProxy();
    TabsApiProxyImpl.setInstance(testTabsApiProxy);

    tabElement = document.createElement('tabstrip-tab');
    tabElement.tab = createTabData();
    document.body.appendChild(tabElement);
  });

  test('slideOut animates out the element', async () => {
    testTabsApiProxy.setVisible(true);
    const tabElementStyle = window.getComputedStyle(tabElement);
    const animationPromise = tabElement.slideOut();
    // Before animation completes.
    assertEquals('1', tabElementStyle.opacity);
    assertEquals('none', tabElementStyle.maxWidth);
    assertEquals('matrix(1, 0, 0, 1, 0, 0)', tabElementStyle.transform);
    assertTrue(tabElement.isConnected);
    await animationPromise;
    // After animation completes.
    assertFalse(tabElement.isConnected);
  });

  test('slideOut does not animate when tab strip is hidden', () => {
    testTabsApiProxy.setVisible(false);
    assertTrue(tabElement.isConnected);
    tabElement.slideOut();

    // The tab should immediately be disconnected without waiting for the
    // animation to finish.
    assertFalse(tabElement.isConnected);
  });

  test('slideOut resolves immediately when tab strip becomes hidden', () => {
    testTabsApiProxy.setVisible(true);
    assertTrue(tabElement.isConnected);
    tabElement.slideOut();

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

    // The tab should immediately be disconnected without waiting for the
    // animation to finish.
    assertFalse(tabElement.isConnected);
  });

  test('toggles an [active] attribute when active', () => {
    tabElement.tab = createTabData({active: true});
    assertTrue(tabElement.hasAttribute('active'));
    tabElement.tab = createTabData({active: false});
    assertFalse(tabElement.hasAttribute('active'));
  });

  test('sets [aria-selected] attribute when active', () => {
    tabElement.tab = createTabData({active: true});
    assertEquals('true', queryTab().getAttribute('aria-selected'));
    tabElement.tab = createTabData({active: false});
    assertEquals('false', queryTab().getAttribute('aria-selected'));
  });

  test('hides entire favicon container when showIcon is false', () => {
    // disable transitions
    tabElement.style.setProperty('--tabstrip-tab-transition-duration', '0ms');

    const faviconContainerStyle = window.getComputedStyle(
        tabElement.shadowRoot!.querySelector('#faviconContainer')!);

    tabElement.tab = createTabData({showIcon: true});
    assertEquals(
        faviconContainerStyle.maxWidth,
        faviconContainerStyle.getPropertyValue('--favicon-size').trim());
    assertEquals(faviconContainerStyle.opacity, '1');

    tabElement.tab = createTabData({showIcon: false});
    assertEquals(faviconContainerStyle.maxWidth, '0px');
    assertEquals(faviconContainerStyle.opacity, '0');
  });

  test('updates dimensions based on CSS variables when pinned', () => {
    const tabElementStyle = window.getComputedStyle(tabElement);
    const expectedSize = '100px';
    tabElement.style.setProperty('--tabstrip-pinned-tab-size', expectedSize);

    tabElement.tab = createTabData({pinned: true});
    assertEquals(expectedSize, tabElementStyle.width);
    assertEquals(expectedSize, tabElementStyle.height);

    tabElement.style.setProperty('--tabstrip-tab-width', '100px');
    tabElement.style.setProperty('--tabstrip-tab-height', '150px');
    tabElement.tab = createTabData({pinned: false});
    assertEquals('100px', tabElementStyle.width);
    assertEquals('150px', tabElementStyle.height);
  });

  test('show spinner when loading or waiting', () => {
    function assertSpinnerVisible(color: string) {
      const spinnerStyle = window.getComputedStyle(
          tabElement.shadowRoot!.querySelector('#progressSpinner')!);
      assertEquals('block', spinnerStyle.display);
      assertEquals(color, spinnerStyle.backgroundColor);

      // Also assert it becomes hidden when network state is NONE
      tabElement.tab = createTabData({networkState: TabNetworkState.kNone});
      assertEquals('none', spinnerStyle.display);
    }

    tabElement.style.setProperty(
        '--color-web-ui-tab-strip-tab-loading-spinning', 'rgb(255, 0, 0)');
    tabElement.tab = createTabData({networkState: TabNetworkState.kLoading});
    assertSpinnerVisible('rgb(255, 0, 0)');

    tabElement.style.setProperty(
        '--color-web-ui-tab-strip-tab-waiting-spinning', 'rgb(0, 255, 0)');
    tabElement.tab = createTabData({networkState: TabNetworkState.kWaiting});
    assertSpinnerVisible('rgb(0, 255, 0)');
  });

  test('shows blocked indicator when tab is blocked', () => {
    const blockIndicatorStyle = window.getComputedStyle(
        tabElement.shadowRoot!.querySelector('#blocked')!);
    tabElement.tab = createTabData({blocked: true});
    assertEquals('block', blockIndicatorStyle.display);
    tabElement.tab = createTabData({blocked: true, active: true});
    assertEquals('none', blockIndicatorStyle.display);
    tabElement.tab = createTabData({blocked: false});
    assertEquals('none', blockIndicatorStyle.display);
  });

  test(
      'hides the favicon and shows the crashed icon when tab is crashed',
      () => {
        // disable transitions
        tabElement.style.setProperty(
            '--tabstrip-tab-transition-duration', '0ms');

        const faviconStyle = window.getComputedStyle(
            tabElement.shadowRoot!.querySelector('#favicon')!);
        const crashedIconStyle = window.getComputedStyle(
            tabElement.shadowRoot!.querySelector('#crashedIcon')!);

        tabElement.tab = createTabData({crashed: true});
        assertEquals(faviconStyle.opacity, '0');
        assertEquals(crashedIconStyle.opacity, '1');

        tabElement.tab = createTabData({crashed: false});
        assertEquals(faviconStyle.opacity, '1');
        assertEquals(crashedIconStyle.opacity, '0');
      });

  test('clicking on the element activates the tab', async () => {
    queryTab().click();
    const tabId = await testTabsApiProxy.whenCalled('activateTab');
    assertEquals(tabId, tab.id);
  });

  test('ClickingElementClearsTouchPressedState', async () => {
    const touchPressedAttrib = 'touch_pressed_';
    tabElement.setTouchPressed(true);
    assertTrue(tabElement.hasAttribute(touchPressedAttrib));
    queryTab().click();
    assertFalse(tabElement.hasAttribute(touchPressedAttrib));
  });

  test('sets the title', () => {
    assertEquals(
        tab.title,
        tabElement.shadowRoot!.querySelector<HTMLElement>(
                                  '#titleText')!.innerText);
  });

  test('sets the loading title while loading', () => {
    const loadingTabWithoutTitle = createTabData({
      networkState: TabNetworkState.kWaiting,
      shouldHideThrobber: false,
    });
    delete (loadingTabWithoutTitle as Partial<Tab>).title;
    tabElement.tab = loadingTabWithoutTitle;
    assertEquals(
        strings['loadingTab'],
        tabElement.shadowRoot!.querySelector<HTMLElement>(
                                  '#titleText')!.innerText);
  });

  test('exposes the tab ID to an attribute', () => {
    tabElement.tab = createTabData({id: 1001});
    assertEquals('1001', tabElement.getAttribute('data-tab-id'));
  });

  test('closes the tab when clicking close button', async () => {
    tabElement.shadowRoot!.querySelector<HTMLElement>('#close')!.click();
    const [tabId, closeTabAction] =
        await testTabsApiProxy.whenCalled('closeTab');
    assertEquals(tabId, tab.id);
    assertEquals(closeTabAction, CloseTabAction.CLOSE_BUTTON);
  });

  test('closes the tab on swipe', async () => {
    tabElement.dispatchEvent(new CustomEvent('swipe'));
    const [tabId, closeTabAction] =
        await testTabsApiProxy.whenCalled('closeTab');
    assertEquals(tabId, tab.id);
    assertEquals(closeTabAction, CloseTabAction.SWIPED_TO_CLOSE);
  });

  test('sets the favicon to the favicon URL', () => {
    const expectedFaviconUrl = 'data:mock-favicon';
    tabElement.tab = createTabData({faviconUrl: {url: expectedFaviconUrl}});
    const faviconElement =
        tabElement.shadowRoot!.querySelector<HTMLElement>('#favicon')!;
    assertEquals(
        faviconElement.style.backgroundImage, `url("${expectedFaviconUrl}")`);
  });

  test(
      'sets the favicon to the default favicon URL if there is none provided',
      () => {
        const updatedTab = createTabData();
        updatedTab.faviconUrl = null;
        tabElement.tab = updatedTab;
        const faviconElement =
            tabElement.shadowRoot!.querySelector<HTMLElement>('#favicon')!;
        assertEquals(faviconElement.style.backgroundImage, getFavicon(''));
      });

  test('removes the favicon if the tab is waiting', () => {
    tabElement.tab = createTabData({
      faviconUrl: {url: 'data:mock-favicon'},
      networkState: TabNetworkState.kWaiting,
    });
    const faviconElement =
        tabElement.shadowRoot!.querySelector<HTMLElement>('#favicon')!;
    assertEquals(faviconElement.style.backgroundImage, 'none');
  });

  test(
      'removes the favicon if the tab is loading with a default favicon',
      () => {
        tabElement.tab = createTabData({
          faviconUrl: {url: 'data:mock-favicon'},
          isDefaultFavicon: true,
          networkState: TabNetworkState.kWaiting,
        });
        const faviconElement =
            tabElement.shadowRoot!.querySelector<HTMLElement>('#favicon')!;
        assertEquals(faviconElement.style.backgroundImage, 'none');
      });

  test('hides the thumbnail if there is no source yet', () => {
    const thumbnailImage =
        tabElement.shadowRoot!.querySelector<HTMLElement>('#thumbnailImg')!;
    assertFalse(thumbnailImage.hasAttribute('src'));
    assertEquals(window.getComputedStyle(thumbnailImage).display, 'none');
  });

  test('updates the thumbnail source', async () => {
    const thumbnailSource = 'data:mock-thumbnail-source';
    tabElement.updateThumbnail(thumbnailSource);
    assertEquals(
        tabElement.shadowRoot!.querySelector<HTMLImageElement>(
                                  '#thumbnailImg')!.src,
        thumbnailSource);
  });

  test('setting dragging state toggles an attribute', () => {
    tabElement.setDragging(true);
    assertTrue(tabElement.hasAttribute('dragging_'));
    tabElement.setDragging(false);
    assertFalse(tabElement.hasAttribute('dragging_'));
  });

  test('gets the drag image', () => {
    assertEquals(
        tabElement.getDragImage(),
        tabElement.shadowRoot!.querySelector('#dragImage'));
  });

  test('activating closes WebUI container', () => {
    assertEquals(testTabsApiProxy.getCallCount('closeContainer'), 0);
    queryTab().click();
    assertEquals(testTabsApiProxy.getCallCount('closeContainer'), 1);
  });

  test('sets an accessible title', () => {
    const titleTextElement =
        tabElement.shadowRoot!.querySelector<HTMLElement>('#titleText')!;
    assertEquals(titleTextElement.getAttribute('aria-label'), tab.title);

    tabElement.tab = createTabData({
      crashed: true,
      title: 'My tab',
    });
    assertEquals(
        titleTextElement.getAttribute('aria-label'), 'My tab has crashed');

    tabElement.tab = createTabData({
      crashed: false,
      networkState: TabNetworkState.kError,
      title: 'My tab',
    });
    assertEquals(
        titleTextElement.getAttribute('aria-label'),
        'My tab has a network error');
  });

  test('focusing on the host moves focus to inner tab element', () => {
    tabElement.focus();
    assertEquals(tabElement.shadowRoot!.activeElement, queryTab());
  });

  test('supports Enter and Space key for activating tab', async () => {
    const innerTabElement = queryTab();
    innerTabElement.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter'}));
    assertEquals(await testTabsApiProxy.whenCalled('activateTab'), tab.id);
    testTabsApiProxy.reset();

    innerTabElement.dispatchEvent(new KeyboardEvent('keydown', {key: ' '}));
    assertEquals(await testTabsApiProxy.whenCalled('activateTab'), tab.id);
    testTabsApiProxy.reset();
  });

  test('DragImagePreservesAspectRatio', () => {
    const originalBoundingBox = queryTab().getBoundingClientRect();
    const originalAspectRatio =
        originalBoundingBox.width / originalBoundingBox.height;
    tabElement.setDragging(true);
    const dragImageBoundingBox =
        tabElement.getDragImage().querySelector<HTMLElement>(
                                     '#tab')!.getBoundingClientRect();
    const dragImageAspectRatio =
        dragImageBoundingBox.width / dragImageBoundingBox.height;
    // Check the Math.floor values of these values to prevent possible
    // flakiness caused by comparing float values.
    assertEquals(
        Math.floor(originalAspectRatio), Math.floor(dragImageAspectRatio));
  });

  test('RightClickOpensContextMenu', async () => {
    queryTab().dispatchEvent(new PointerEvent('pointerup', {
      pointerType: 'mouse',
      button: 2,
      clientX: 50,
      clientY: 100,
    }));
    const [id, x, y] = await testTabsApiProxy.whenCalled('showTabContextMenu');
    assertEquals(tab.id, id);
    assertEquals(50, x);
    assertEquals(100, y);
  });
});