chromium/chrome/test/data/webui/extensions/test_util.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 Common utilities for extension ui tests. */
import type {ItemDelegate} from 'chrome://extensions/extensions.js';
import {CrLitElement} from 'chrome://resources/lit/v3_0/lit.rollup.js';
import {assertDeepEquals, assertEquals, assertTrue} from 'chrome://webui-test/chai_assert.js';
import {FakeChromeEvent} from 'chrome://webui-test/fake_chrome_event.js';
import {MockController, MockMethod} from 'chrome://webui-test/mock_controller.js';
import {isChildVisible} from 'chrome://webui-test/test_util.js';

/** A mock to test that clicking on an element calls a specific method. */
export class ClickMock {
  /**
   * Tests clicking on an element and expecting a call.
   * @param element The element to click on.
   * @param callName The function expected to be called.
   * @param expectedArgs The arguments the function is
   *     expected to be called with.
   * @param returnValue The value to return from the function call.
   */
  async testClickingCalls(
      element: HTMLElement, callName: string, expectedArgs: any[],
      returnValue?: any) {
    const mock = new MockController();
    const mockMethod = mock.createFunctionMock(this, callName);
    mockMethod.returnValue = returnValue;
    MockMethod.prototype.addExpectation.apply(mockMethod, expectedArgs);
    element.click();

    if (element instanceof CrLitElement) {
      await (element as CrLitElement).updateComplete;
    }

    mock.verifyMocks();
  }
}

interface ListenerInfo {
  satisfied: boolean;
  args: any;
}

/**
 * A mock to test receiving expected events and verify that they were called
 * with the proper detail values.
 */
export class ListenerMock {
  private listeners_: {[eventName: string]: ListenerInfo} = {};

  private onEvent_(eventName: string, e: Event) {
    assertTrue(this.listeners_.hasOwnProperty(eventName));
    if (this.listeners_[eventName]!.satisfied) {
      // Event was already called and checked. We could always make this
      // more intelligent by allowing for subsequent calls, removing the
      // listener, etc, but there's no need right now.
      return;
    }
    const expected = this.listeners_[eventName]!.args || {};
    assertDeepEquals((e as CustomEvent).detail, expected);
    this.listeners_[eventName]!.satisfied = true;
  }

  /**
   * Adds an expected event.
   * @param eventArgs If omitted, will check that the details
   *     are empty (i.e., {}).
   */
  addListener(target: EventTarget, eventName: string, eventArgs: any) {
    assertTrue(!this.listeners_.hasOwnProperty(eventName));
    this.listeners_[eventName] = {args: eventArgs || {}, satisfied: false};
    target.addEventListener(eventName, this.onEvent_.bind(this, eventName));
  }

  /** Verifies the expectations set. */
  verify() {
    const missingEvents = [];
    for (const key in this.listeners_) {
      if (!this.listeners_[key]!.satisfied) {
        missingEvents.push(key);
      }
    }
    assertEquals(0, missingEvents.length, JSON.stringify(missingEvents));
  }
}

/**
 * A mock delegate for the item, capable of testing functionality.
 */
export class MockItemDelegate extends ClickMock implements ItemDelegate {
  itemStateChangedTarget: FakeChromeEvent = new FakeChromeEvent();
  deleteItem(_id: string) {}
  deleteItems(_ids: string[]) {
    return Promise.resolve();
  }
  uninstallItem(_id: string) {
    return Promise.resolve();
  }
  setItemSafetyCheckWarningAcknowledged(_id: string) {}
  setItemEnabled(_id: string, _isEnabled: boolean) {}
  setItemAllowedIncognito(_id: string, _isAllowedIncognito: boolean) {}
  setItemAllowedOnFileUrls(_id: string, _isAllowedOnFileUrls: boolean) {}
  setItemHostAccess(
      _id: string, _hostAccess: chrome.developerPrivate.HostAccess) {}
  setItemCollectsErrors(_id: string, _collectsErrors: boolean) {}
  setItemPinnedToToolbar(_id: string, _pinnedToToolbar: boolean) {}
  inspectItemView(_id: string, _view: chrome.developerPrivate.ExtensionView) {}
  openUrl(_url: string) {}


  reloadItem(_id: string) {
    return Promise.resolve();
  }

  repairItem(_id: string) {}
  showItemOptionsPage(_extension: chrome.developerPrivate.ExtensionInfo) {}
  showInFolder(_id: string) {}

  getExtensionSize(_id: string) {
    return Promise.resolve('10 MB');
  }

  addRuntimeHostPermission(_id: string, _host: string) {
    return Promise.resolve();
  }

  removeRuntimeHostPermission(_id: string, _host: string) {
    return Promise.resolve();
  }

  setShowAccessRequestsInToolbar(_id: string, _showRequests: boolean) {}

  recordUserAction(_metricName: string) {}

  getItemStateChangedTarget() {
    return this.itemStateChangedTarget;
  }
}

/**
 * A mock to intercept User Action logging calls and verify how many times they
 * were called.
 */
export class MetricsPrivateMock {
  userActionMap: Map<string, number> = new Map();

  getUserActionCount(metricName: string): number {
    return this.userActionMap.get(metricName) || 0;
  }

  recordUserAction(metricName: string) {
    this.userActionMap.set(metricName, this.getUserActionCount(metricName) + 1);
  }
}

export function isElementVisible(element: HTMLElement): boolean {
  const rect = element.getBoundingClientRect();
  return rect.width * rect.height > 0;  // Width and height is never negative.
}

/**
 * Tests that the element's visibility matches |expectedVisible| and,
 * optionally, has specific content if it is visible.
 * @param parentEl The parent element to query for the element.
 * @param selector The selector to find the element.
 * @param expectedVisible Whether the element should be visible.
 * @param expectedText The expected textContent value.
 */
export function testVisible(
    parentEl: HTMLElement, selector: string, expectedVisible: boolean,
    expectedText?: string) {
  const visible = isChildVisible(parentEl, selector);
  assertEquals(expectedVisible, visible, selector);
  if (expectedVisible && visible && expectedText) {
    const element = parentEl.shadowRoot!.querySelector(selector)!;
    assertEquals(expectedText, element.textContent!.trim(), selector);
  }
}

/**
 * Creates an ExtensionInfo object.
 * @param properties A set of properties that will be used on the resulting
 *     ExtensionInfo (otherwise defaults will be used).
 */
export function createExtensionInfo(
    properties?: Partial<chrome.developerPrivate.ExtensionInfo>):
    chrome.developerPrivate.ExtensionInfo {
  const id = properties && properties.hasOwnProperty('id') ? properties['id']! :
                                                             'a'.repeat(32);
  const baseUrl = 'chrome-extension://' + id + '/';
  return Object.assign(
      {
        commands: [],
        errorCollection: {
          isEnabled: false,
          isActive: false,
        },
        dependentExtensions: [],
        description: 'This is an extension',
        disableReasons: {
          suspiciousInstall: false,
          corruptInstall: false,
          updateRequired: false,
          publishedInStoreRequired: false,
          blockedByPolicy: false,
          custodianApprovalRequired: false,
          parentDisabledPermissions: false,
          reloading: false,
          unsupportedManifestVersion: false,
        },
        fileAccess: {
          isEnabled: false,
          isActive: false,
        },
        homePage: {specified: false, url: ''},
        iconUrl: 'chrome://extension-icon/' + id + '/24/0',
        id: id,
        incognitoAccess: {isEnabled: true, isActive: false},
        installWarnings: [],
        location: 'FROM_STORE',
        manifestErrors: [],
        manifestHomePageUrl: '',
        mustRemainInstalled: false,
        name: 'Wonderful Extension',
        offlineEnabled: false,
        runtimeErrors: [],
        runtimeWarnings: [],
        permissions: {simplePermissions: [], canAccessSiteData: false},
        state: 'ENABLED',
        type: 'EXTENSION',
        updateUrl: '',
        userMayModify: true,
        version: '2.0',
        views: [{url: baseUrl + 'foo.html'}, {url: baseUrl + 'bar.html'}],
        webStoreUrl: '',
        showSafeBrowsingAllowlistWarning: false,
        showAccessRequestsInToolbar: false,
        isAffectedByMV2Deprecation: false,
        didAcknowledgeMV2DeprecationNotice: false,
        safetyCheckWarningReason: 'UNPUBLISHED',
      },
      properties || {});
}

/**
 * Finds all nodes matching |query| under |root|, within self and children's
 * Shadow DOM.
 */
export function findMatches(
    root: HTMLElement|Document, query: string): HTMLElement[] {
  const elements = new Set<HTMLElement>();
  function doSearch(node: Node) {
    if (node.nodeType === Node.ELEMENT_NODE) {
      const matches = (node as Element).querySelectorAll<HTMLElement>(query);
      for (const match of matches) {
        elements.add(match);
      }
    }
    let child = node.firstChild;
    while (child !== null) {
      doSearch(child);
      child = child.nextSibling;
    }
    const shadowRoot = (node as HTMLElement).shadowRoot;
    if (shadowRoot) {
      doSearch(shadowRoot);
    }
  }
  doSearch(root);
  return Array.from(elements);
}