chromium/chrome/test/data/webui/chromeos/emoji_picker/emoji_picker_test_util.ts

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

import {EMOJI_PICKER_READY, EmojiButton, EmojiGroupComponent, EmojiPickerApiProxy, EmojiPickerApp} from 'chrome://emoji-picker/emoji_picker.js';
import {assert} from 'chrome://resources/js/assert.js';
import {flush} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {assertTrue} from 'chrome://webui-test/chai_assert.js';

export function assertCloseTo(actual: number, expected: number) {
  assertTrue(
      Math.abs(1 - actual / expected) <= 0.001,
      `expected ${expected} to be close to ${actual}`);
}

/**
 * Queries for an element through a path of custom elements.
 * This is needed because querySelector() does not query into
 * custom elements' shadow roots.
 */
export function deepQuerySelector(root: Element, path: string[]): HTMLElement|
    null {
  assert(root, 'deepQuerySelector called with null root');

  let el: ShadowRoot|Element|null = root;

  for (const part of path) {
    if (el.shadowRoot) {
      el = el.shadowRoot;
    }

    el = el.querySelector(part);
    if (!el) {
      break;
    }
  }

  return el as HTMLElement | null;
}

/**
 * Constructs a promise which resolves when the given condition function
 * evaluates to a truthy value.
 */
export async function waitForCondition<T>(
    condition: () => T, message: string,
    maxWait = 5000): Promise<NonNullable<T>> {
  const interval = 10;
  let waiting = 0;

  /** @type {T} */
  let result;
  while (!(result = condition()) && waiting < maxWait) {
    await timeout(interval);
    waiting += interval;
  }
  assert(
      result,
      message || 'waitForCondition timed out after ' + maxWait + ' ms.');

  return result;
}

/**
 * Constructs a promise which resolves after the given amount of time.
 */
export function timeout(ms: number): Promise<void> {
  return new Promise(resolve => setTimeout(resolve, ms));
}

/**
 * Constructs a promise which resolves after 0 seconds.
 */
export function completePendingMicrotasks(): Promise<void> {
  return new Promise((resolve) => {
    setTimeout(resolve, 0);
  });
}

/**
 * Constructs a promise which resolves when the given promise resolves,
 * or fails after the given timeout - whichever occurs first.
 */
export function waitWithTimeout<T>(
    promise: Promise<T>, ms: number, message: string): Promise<T> {
  message = message || 'waiting for promise timed out after ' + ms + ' ms.';
  return Promise.race(
      [promise, timeout(ms).then(() => Promise.reject(new Error(message)))]);
}

/**
 * Constructs a promise which resolves when the given element receives
 * an event of the given type.
 * Note: this function should be called *before* event is expected to set up
 * the handler, then it should be awaited when the event is required.
 */
export function waitForEvent(
    element: Element, eventType: string): Promise<Event> {
  return new Promise(
      resolve => element.addEventListener(eventType, resolve, {once: true}));
}


/**
 * Simulates a mouse click event on the given element.
 */
export function dispatchMouseEvent(
    element: Element, button: number, eventType = 'contextmenu') {
  element.dispatchEvent(new MouseEvent(eventType, {
    bubbles: true,
    cancelable: true,
    view: window,
    button: button,
    buttons: 0,
    clientX: element.getBoundingClientRect().x,
    clientY: element.getBoundingClientRect().y,
  }));
}

const ACTIVE_EMOJI_GROUP_CLASS = 'emoji-group-active';
const ACTIVE_TEXT_GROUP_CLASS = 'text-group-active';
/**
 * Checks if the given emoji-group-button or text-group-button element is
 * activated.
 */
export function isGroupButtonActive(element: Element|null): boolean {
  assert(element, 'group button element should not be null');
  return element!.classList.contains(ACTIVE_EMOJI_GROUP_CLASS) ||
      element!.classList.contains(ACTIVE_TEXT_GROUP_CLASS);
}

/**
 * Set up emoji picker and some helper functions for tests.
 * Ideally this is called at the top level (outside setup()), but a bunch of
 * tests rely on using this to reset state - in that case this must go inisde
 * setup() which is more like jasmine beforeEach()
 */
export function initialiseEmojiPickerForTest(
    incognito = false, localStorage: Array<{key: string, value: string}> = []) {
  const setIncognito = (incognito: boolean) => {
    EmojiPickerApiProxy.getInstance().isIncognitoTextField = async () =>
        ({incognito});
  };

  // Set default incognito state to False.
  setIncognito(incognito);
  EmojiPickerApp.configs = () => ({
    dataUrls: {
      emoji: [
        '/emoji_test_ordering_start.json',
        '/emoji_test_ordering_remaining.json',
      ],
      emoticon: ['/emoticon_test_ordering.json'],
      symbol: ['/symbol_test_ordering.json'],
      gif: [],
    },
  });

  // Reset DOM state.
  assert(window.trustedTypes);
  document.body.innerHTML = window.trustedTypes.emptyHTML;
  window.localStorage.clear();

  for (const {key, value} of localStorage) {
    window.localStorage.setItem(key, value);
  }

  let emojiPicker =
      document.createElement('emoji-picker-app') as EmojiPickerApp;

  const findInEmojiPicker = (...path: string[]) =>
      deepQuerySelector(emojiPicker, path);

  const waitUntilFindInEmojiPicker = async(...path: string[]):
      Promise<HTMLElement> => {
        await waitForCondition(
            () => findInEmojiPicker(...path) !== null,
            'element should not be null');
        return findInEmojiPicker(...path)!;
      };

  const findEmojiFirstButton = (...path: string[]) => {
    const emojiElement = findInEmojiPicker(...path);
    return (emojiElement as EmojiGroupComponent | null)?.firstEmojiButton();
  };

  const findEmojiButtonByText = (text: string, group: HTMLElement) => {
    const buttons = Array.from(
        group.shadowRoot!.querySelectorAll<HTMLElement>('.emoji-button'));
    return buttons.find(button => button.innerText === text) ?? null;
  };

  const findGroup = (groupId: string) =>
      findInEmojiPicker(`[data-group="${groupId}"] > emoji-group`);

  const findSearchGroup = (category: string) =>
      findInEmojiPicker('emoji-search', `emoji-group[category="${category}"]`);

  const expectEmojiButton = (text: string, getGroup = () => findGroup('0')) =>
      waitForCondition(() => {
        const group = getGroup();
        return group ? findEmojiButtonByText(text, group) : null;
      }, `wait for emoji ${text} to render`);

  const expectEmojiButtons =
      (texts: string[], getGroup?: () => HTMLElement | null) =>
          Promise.all(texts.map(text => expectEmojiButton(text, getGroup)));

  const findVariant = (text: string, button: HTMLElement) => {
    const variants =
        button.parentElement?.querySelector<HTMLElement>('emoji-variants');

    if (!variants || variants.style.display === 'none') {
      return null;
    }

    const variantButtons =
        Array.from(variants?.shadowRoot!.querySelectorAll('emoji-button'));
    const component =
        variantButtons.find(button => (button as EmojiButton).emoji === text);

    return component?.shadowRoot!.querySelector<HTMLElement>('#emoji-button') ??
        null;
  };

  const clickVariant = async (text: string, button: HTMLElement) => {
    dispatchMouseEvent(button, 2);
    const variant = await waitForCondition(
        () => findVariant(text, button),
        `wait for variants for emoji ${text} to render`);
    variant.click();
  };

  const scrollDown = (height: number) => {
    const thisRect = emojiPicker.$.groups;
    if (thisRect) {
      thisRect.scrollTop += height;
    }
  };

  const scrollToBottom = () => {
    const thisRect = emojiPicker.$.groups;
    if (!thisRect) {
      return;
    }
    const searchResultRect =
        emojiPicker.getActiveGroupAndId(thisRect.getBoundingClientRect()).group;
    if (searchResultRect) {
      thisRect.scrollTop += searchResultRect.getBoundingClientRect().bottom;
    }
  };

  // Wait until emoji data is loaded before executing tests.
  const createReadyPromise = () => new Promise<void>((resolve) => {
    emojiPicker.addEventListener(EMOJI_PICKER_READY, () => {
      flush();
      resolve();
    });
    document.body.appendChild(emojiPicker);
  });

  const reload = async () => {
    emojiPicker.remove();
    emojiPicker = document.createElement('emoji-picker-app');
    await createReadyPromise();
  };

  return {
    emojiPicker,
    findInEmojiPicker,
    waitUntilFindInEmojiPicker,
    findEmojiFirstButton,
    expectEmojiButton,
    expectEmojiButtons,
    clickVariant,
    findGroup,
    findSearchGroup,
    readyPromise: createReadyPromise(),
    reload,
    setIncognito,
    scrollDown,
    scrollToBottom,
  };
}

/**
 * Asserts the alt attribute of EmojiImage.
 */
export function assertEmojiImageAlt(element: HTMLElement|undefined, alt: string): void {
  assert(element!.shadowRoot?.querySelector('img')?.alt, alt);
}