chromium/chrome/test/data/webui/extensions/item_test.ts

// 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.

/** @fileoverview Suite of tests for extension-item. */

import type {ExtensionsItemElement, IronIconElement} from 'chrome://extensions/extensions.js';
import {navigation, Page} from 'chrome://extensions/extensions.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {flush} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {assertDeepEquals, assertEquals, assertFalse, assertTrue} from 'chrome://webui-test/chai_assert.js';
import {isChildVisible, microtasksFinished} from 'chrome://webui-test/test_util.js';

import {TestService} from './test_service.js';
import {createExtensionInfo, MockItemDelegate, testVisible} from './test_util.js';

/**
 * The data used to populate the extension item.
 */
const extensionData: chrome.developerPrivate.ExtensionInfo =
    createExtensionInfo();

// The normal elements, which should always be shown.
const normalElements = [
  {selector: '#name', text: extensionData.name},
  {selector: '#icon'},
  {selector: '#description', text: extensionData.description},
  {selector: '#enableToggle'},
  {selector: '#detailsButton'},
  {selector: '#removeButton'},
];
// The developer elements, which should only be shown if in developer
// mode *and* showing details.
const devElements = [
  {selector: '#version', text: extensionData.version},
  {selector: '#extension-id', text: `ID: ${extensionData.id}`},
  {selector: '#inspect-views'},
  {selector: '#inspect-views a[is="action-link"]', text: 'foo.html,'},
  {
    selector: '#inspect-views a[is="action-link"]:nth-of-type(2)',
    text: '1 moreā€¦',
  },
];

/**
 * Tests that the elements' visibility matches the expected visibility.
 */
function testElementsVisibility(
    item: HTMLElement, elements: Array<{selector: string, text?: string}>,
    visibility: boolean): void {
  elements.forEach(function(element) {
    testVisible(item, element.selector, visibility, element.text);
  });
}

/** Tests that normal elements are visible. */
function testNormalElementsAreVisible(item: HTMLElement): void {
  testElementsVisibility(item, normalElements, true);
}

/** Tests that dev elements are visible. */
function testDeveloperElementsAreVisible(item: HTMLElement): void {
  testElementsVisibility(item, devElements, true);
}

/** Tests that dev elements are hidden. */
function testDeveloperElementsAreHidden(item: HTMLElement): void {
  testElementsVisibility(item, devElements, false);
}

suite('ExtensionItemTest', function() {
  /**
   * Extension item created before each test.
   */
  let item: ExtensionsItemElement;

  let mockDelegate: MockItemDelegate;

  // Initialize an extension item before each test.
  setup(function() {
    document.body.innerHTML = window.trustedTypes!.emptyHTML;
    mockDelegate = new MockItemDelegate();
    item = document.createElement('extensions-item');
    item.data = createExtensionInfo();
    item.delegate = mockDelegate;
    document.body.appendChild(item);
    const toastManager = document.createElement('cr-toast-manager');
    document.body.appendChild(toastManager);
  });

  test('ElementVisibilityNormalState', function() {
    testNormalElementsAreVisible(item);
    testDeveloperElementsAreHidden(item);

    assertTrue(item.$.enableToggle.checked);
    item.set('data.state', 'DISABLED');
    assertFalse(item.$.enableToggle.checked);
    item.set('data.state', 'BLOCKLISTED');
    assertFalse(item.$.enableToggle.checked);
  });

  test('ElementVisibilityDeveloperState', function() {
    item.set('inDevMode', true);

    testNormalElementsAreVisible(item);
    testDeveloperElementsAreVisible(item);

    // Developer reload button should be visible only for enabled unpacked
    // extensions.
    testVisible(item, '#dev-reload-button', false);

    item.set('data.location', chrome.developerPrivate.Location.UNPACKED);
    flush();
    testVisible(item, '#dev-reload-button', true);

    item.set('data.state', chrome.developerPrivate.ExtensionState.DISABLED);
    flush();
    testVisible(item, '#dev-reload-button', false);

    item.set('data.disableReasons.reloading', true);
    flush();
    testVisible(item, '#dev-reload-button', true);

    item.set('data.disableReasons.reloading', false);
    flush();
    item.set('data.state', chrome.developerPrivate.ExtensionState.TERMINATED);
    flush();
    testVisible(item, '#dev-reload-button', false);
    testVisible(item, '#enableToggle', false);
  });

  /** Tests that the delegate methods are correctly called. */
  test('ClickableItems', async function() {
    item.set('inDevMode', true);

    await mockDelegate.testClickingCalls(
        item.$.removeButton, 'deleteItem', [item.data.id]);
    await mockDelegate.testClickingCalls(
        item.$.enableToggle, 'setItemEnabled', [item.data.id, false]);
    await mockDelegate.testClickingCalls(
        item.shadowRoot!.querySelector<HTMLElement>(
            '#inspect-views a[is="action-link"]')!,
        'inspectItemView', [item.data.id, item.data.views[0]]);

    // Setup for testing navigation buttons.
    let currentPage = null;
    navigation.addListener(newPage => {
      currentPage = newPage;
    });

    item.$.detailsButton.click();
    assertDeepEquals(
        currentPage, {page: Page.DETAILS, extensionId: item.data.id});

    // Reset current page and test inspect-view navigation.
    navigation.navigateTo({page: Page.LIST});
    currentPage = null;
    item.shadowRoot!
        .querySelector<HTMLElement>(
            '#inspect-views a[is="action-link"]:nth-of-type(2)')!.click();
    assertDeepEquals(
        currentPage, {page: Page.DETAILS, extensionId: item.data.id});

    item.set('data.disableReasons.corruptInstall', true);
    flush();
    await mockDelegate.testClickingCalls(
        item.shadowRoot!.querySelector<HTMLElement>('#repair-button')!,
        'repairItem', [item.data.id]);
    testVisible(item, '#enableToggle', false);
    item.set('data.disableReasons.corruptInstall', false);
    flush();

    item.set('data.state', chrome.developerPrivate.ExtensionState.TERMINATED);
    flush();
    await mockDelegate.testClickingCalls(
        item.shadowRoot!.querySelector<HTMLElement>(
            '#terminated-reload-button')!,
        'reloadItem', [item.data.id], Promise.resolve());

    item.set('data.location', chrome.developerPrivate.Location.UNPACKED);
    item.set('data.state', chrome.developerPrivate.ExtensionState.ENABLED);
    flush();
  });

  /** Tests that the reload button properly fires the load-error event. */
  test('FailedReloadFiresLoadError', async function() {
    item.set('inDevMode', true);
    item.set('data.location', chrome.developerPrivate.Location.UNPACKED);
    flush();
    testVisible(item, '#dev-reload-button', true);

    // Check clicking the reload button. The reload button should fire a
    // load-error event if and only if the reload fails (indicated by a
    // rejected promise).
    // This is a bit of a pain to verify because the promises finish
    // asynchronously, so we have to use setTimeout()s.
    let firedLoadError = false;
    item.addEventListener('load-error', () => {
      firedLoadError = true;
    });

    // This is easier to test with a TestBrowserProxy-style delegate.
    const proxyDelegate = new TestService();
    item.delegate = proxyDelegate;

    function verifyEventPromise(expectCalled: boolean): Promise<void> {
      return new Promise((resolve, _reject) => {
        setTimeout(() => {
          assertEquals(expectCalled, firedLoadError);
          resolve();
        });
      });
    }

    item.shadowRoot!.querySelector<HTMLElement>('#dev-reload-button')!.click();
    let id = await proxyDelegate.whenCalled('reloadItem');
    assertEquals(item.data.id, id);
    await verifyEventPromise(false);
    proxyDelegate.resetResolver('reloadItem');
    proxyDelegate.setForceReloadItemError(true);
    item.shadowRoot!.querySelector<HTMLElement>('#dev-reload-button')!.click();
    id = await proxyDelegate.whenCalled('reloadItem');
    assertEquals(item.data.id, id);
    return verifyEventPromise(true);
  });

  test('Description', function() {
    // Description is visible if there are no warnings.
    assertTrue(isChildVisible(item, '#description'));

    // Description is hidden if there is a severe warning.
    item.set('data.disableReasons.corruptInstall', true);
    flush();
    assertFalse(isChildVisible(item, '#description'));

    // Description is hidden if there is a MV2 deprecation warning.
    item.set('data.disableReasons.corruptInstall', false);
    item.set('data.disableReasons.unsupportedManifestVersion', true);
    flush();
    assertFalse(isChildVisible(item, '#description'));

    // Description is hidden if there is an allowlist warning.
    item.set('data.disableReasons.unsupportedManifestVersion', false);
    item.set('data.showSafeBrowsingAllowlistWarning', true);
    flush();
    assertFalse(isChildVisible(item, '#description'));
  });

  test('Warnings', function() {
    // Severe warnings.
    const kCorrupt = 1 << 0;
    const kSuspicious = 1 << 1;
    const kBlocklisted = 1 << 2;
    const kRuntime = 1 << 3;
    // Allowlist warning.
    const kSafeBrowsingAllowlist = 1 << 4;
    // MV2 deprecation warning.
    const kMv2Deprecation = 1 << 5;

    function assertWarnings(mask: number) {
      assertEquals(
          !!(mask & kCorrupt), isChildVisible(item, '#corrupted-warning'));
      assertEquals(
          !!(mask & kSuspicious), isChildVisible(item, '#suspicious-warning'));
      assertEquals(
          !!(mask & kBlocklisted),
          isChildVisible(item, '#blocklisted-warning'));
      assertEquals(
          !!(mask & kRuntime), isChildVisible(item, '#runtime-warnings'));
      assertEquals(
          !!(mask & kSafeBrowsingAllowlist),
          isChildVisible(item, '#allowlist-warning'));
      assertEquals(
          !!(mask & kMv2Deprecation),
          isChildVisible(item, '#mv2-deprecation-warning'));
    }

    assertWarnings(0);

    // Show severe warnings by updating the corresponding properties.
    item.set('data.disableReasons.corruptInstall', true);
    flush();
    assertWarnings(kCorrupt);

    item.set('data.disableReasons.suspiciousInstall', true);
    flush();
    assertWarnings(kCorrupt | kSuspicious);

    item.set('data.blocklistText', 'This item is blocklisted');
    flush();
    assertWarnings(kCorrupt | kSuspicious | kBlocklisted);

    item.set('data.blocklistText', null);
    flush();
    assertWarnings(kCorrupt | kSuspicious);

    item.set('data.runtimeWarnings', ['Dummy warning']);
    flush();
    assertWarnings(kCorrupt | kSuspicious | kRuntime);

    // Reset all properties affecting warnings.
    item.set('data.disableReasons.corruptInstall', false);
    item.set('data.disableReasons.suspiciousInstall', false);
    item.set('data.runtimeWarnings', []);
    flush();
    assertWarnings(0);

    // Show MV2 deprecation warning.
    item.set('data.disableReasons.unsupportedManifestVersion', true);
    flush();
    assertWarnings(kMv2Deprecation);
    // MV2 deprecation warning is hidden if there are any severe warnings.
    item.set('data.disableReasons.suspiciousInstall', true);
    flush();
    assertWarnings(kSuspicious);

    // Reset all properties affecting warnings.
    item.set('data.disableReasons.unsupportedManifestVersion', false);
    item.set('data.disableReasons.suspiciousInstall', false);
    flush();
    assertWarnings(0);

    // Show allowlist warning.
    item.set('data.showSafeBrowsingAllowlistWarning', true);
    flush();
    assertWarnings(kSafeBrowsingAllowlist);
    // Allowlist warning is not visible if there are any severe warnings.
    item.set('data.disableReasons.suspiciousInstall', true);
    flush();
    assertWarnings(kSuspicious);
    // Allowlist warning is not visible if there is a MV2 deprecation warning
    item.set('data.disableReasons.suspiciousInstall', false);
    item.set('data.disableReasons.unsupportedManifestVersion', true);
    flush();
    assertWarnings(kMv2Deprecation);
  });

  test('SourceIndicator', function() {
    assertFalse(isChildVisible(item, '#source-indicator'));
    item.set('data.location', 'UNPACKED');
    flush();
    assertTrue(isChildVisible(item, '#source-indicator'));
    const icon = item.shadowRoot!.querySelector<IronIconElement>(
        '#source-indicator iron-icon');
    assertTrue(!!icon);
    assertEquals('extensions-icons:unpacked', icon.icon);

    item.set('data.location', 'THIRD_PARTY');
    flush();
    assertTrue(isChildVisible(item, '#source-indicator'));
    assertEquals('extensions-icons:input', icon.icon);

    item.set('data.location', 'UNKNOWN');
    flush();
    assertTrue(isChildVisible(item, '#source-indicator'));
    assertEquals('extensions-icons:input', icon.icon);

    item.set('data.location', 'INSTALLED_BY_DEFAULT');
    flush();
    assertFalse(isChildVisible(item, '#source-indicator'));

    item.set('data.location', 'FROM_STORE');
    item.set('data.controlledInfo', {text: 'policy'});
    flush();
    assertTrue(isChildVisible(item, '#source-indicator'));
    assertEquals('extensions-icons:business', icon.icon);

    item.set('data.controlledInfo', null);
    flush();
    assertFalse(isChildVisible(item, '#source-indicator'));
  });

  test('EnableToggle', function() {
    assertFalse(item.$.enableToggle.disabled);

    // Test case where user does not have permission.
    item.set('data.userMayModify', false);
    flush();
    assertTrue(item.$.enableToggle.disabled);
    // Reset state.
    item.set('data.userMayModify', true);
    flush();

    // Test case of a blocklisted extension.
    item.set('data.state', 'BLOCKLISTED');
    flush();
    assertTrue(item.$.enableToggle.disabled);
    // Reset state.
    item.set('data.state', 'ENABLED');
    flush();

    // This section tests that the enable toggle is visible but disabled
    // when disableReasons.blockedByPolicy is true. This test prevents a
    // regression to crbug/1003014.
    item.set('data.disableReasons.blockedByPolicy', true);
    flush();
    testVisible(item, '#enableToggle', true);
    assertTrue(item.$.enableToggle.disabled);
    item.set('data.disableReasons.blockedByPolicy', false);
    flush();

    item.set('data.disableReasons.publishedInStoreRequired', true);
    flush();
    testVisible(item, '#enableToggle', true);
    assertTrue(item.$.enableToggle.disabled);
    item.set('data.disableReasons.publishedInStoreRequired', false);
    flush();

    testVisible(item, '#parentDisabledPermissionsToolTip', false);
    item.set('data.disableReasons.parentDisabledPermissions', true);
    flush();
    testVisible(item, '#enableToggle', true);
    assertFalse(item.$.enableToggle.disabled);
    testVisible(item, '#parentDisabledPermissionsToolTip', true);
    item.set('data.disableReasons.parentDisabledPermissions', false);
    flush();

    item.set('data.disableReasons.custodianApprovalRequired', true);
    flush();
    testVisible(item, '#enableToggle', true);
    assertFalse(item.$.enableToggle.disabled);
    item.set('data.disableReasons.custodianApprovalRequired', false);
    flush();
  });

  test('RemoveButton', function() {
    assertFalse(item.$.removeButton.hidden);
    item.set('data.mustRemainInstalled', true);
    flush();
    assertTrue(item.$.removeButton.hidden);
  });

  test('HtmlInName', function() {
    const name = '<HTML> in the name!';
    item.set('data.name', name);
    flush();
    assertEquals(name, item.$.name.textContent!.trim());
    // "Related to $1" is IDS_MD_EXTENSIONS_EXTENSION_A11Y_ASSOCIATION.
    assertEquals(
        `Related to ${name}`, item.$.a11yAssociation.textContent!.trim());
  });

  test('RepairButton', function() {
    // For most extensions, the "repair" button should be displayed if the
    // extension is detected as corrupted.
    testVisible(item, '#repair-button', false);
    item.set('data.disableReasons.corruptInstall', true);
    flush();
    testVisible(item, '#repair-button', true);
    item.set('data.disableReasons.corruptInstall', false);
    flush();
    testVisible(item, '#repair-button', false);

    // However, the user isn't allowed to initiate a repair for extensions they
    // aren't allowed to modify, so the button shouldn't be visible.
    item.set('data.userMayModify', false);
    item.set('data.disableReasons.corruptInstall', true);
    flush();
    testVisible(item, '#repair-button', false);
  });

  test('InspectableViewSortOrder', function() {
    function getUrl(path: string) {
      return `chrome-extension://${extensionData.id}/${path}`;
    }
    item.set('data.views', [
      {
        type: chrome.developerPrivate.ViewType.EXTENSION_POPUP,
        url: getUrl('popup.html'),
      },
      {
        type: chrome.developerPrivate.ViewType.EXTENSION_BACKGROUND_PAGE,
        url: getUrl('_generated_background_page.html'),
      },
      {
        type: chrome.developerPrivate.ViewType
                  .EXTENSION_SERVICE_WORKER_BACKGROUND,
        url: getUrl('sw.js'),
      },
    ]);
    item.set('inDevMode', true);
    flush();

    // Check that when multiple views are available, the service worker is
    // sorted first.
    assertEquals(
        'service worker,',
        item.shadowRoot!
            .querySelector<HTMLElement>(
                '#inspect-views a:first-of-type')!.textContent!.trim());
  });

  // Test that the correct tooltip text is shown when the enable toggle is
  // hovered over, depending on if the extension is enabled/disabled and its
  // permissions.
  test('EnableExtensionToggleTooltips', async() => {
    const crTooltip =
        item.shadowRoot!.querySelector<HTMLElement>('#enable-toggle-tooltip')!;
    testVisible(item, '#enable-toggle-tooltip', false);

    item.$.enableToggle.dispatchEvent(
        new CustomEvent('pointerenter', {bubbles: true, composed: true}));
    await microtasksFinished();
    testVisible(item, '#enable-toggle-tooltip', true);
    assertEquals(
        loadTimeData.getString('enableToggleTooltipEnabled'),
        crTooltip.textContent!.trim());

    item.set(
        'data.permissions',
        {simplePermissions: ['activeTab'], canAccessSiteData: true});
    flush();
    assertEquals(
        loadTimeData.getString('enableToggleTooltipEnabledWithSiteAccess'),
        crTooltip.textContent!.trim());

    item.set('data.state', 'DISABLED');
    flush();
    assertEquals(
        loadTimeData.getString('enableToggleTooltipDisabled'),
        crTooltip.textContent!.trim());
  });
});