chromium/chrome/test/data/webui/cr_components/help_bubble/help_bubble_mixin_lit_test.ts

// Copyright 2024 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://resources/cr_components/help_bubble/help_bubble.js';

import type {HelpBubbleElement} from 'chrome://resources/cr_components/help_bubble/help_bubble.js';
import type {HelpBubbleClientRemote, HelpBubbleHandlerInterface, HelpBubbleParams} from 'chrome://resources/cr_components/help_bubble/help_bubble.mojom-webui.js';
import {HelpBubbleArrowPosition, HelpBubbleClientCallbackRouter, HelpBubbleClosedReason} from 'chrome://resources/cr_components/help_bubble/help_bubble.mojom-webui.js';
import type {HelpBubbleController} from 'chrome://resources/cr_components/help_bubble/help_bubble_controller.js';
import {ANCHOR_HIGHLIGHT_CLASS} from 'chrome://resources/cr_components/help_bubble/help_bubble_controller.js';
import {HelpBubbleMixinLit} from 'chrome://resources/cr_components/help_bubble/help_bubble_mixin_lit.js';
import type {HelpBubbleProxy} from 'chrome://resources/cr_components/help_bubble/help_bubble_proxy.js';
import {HelpBubbleProxyImpl} from 'chrome://resources/cr_components/help_bubble/help_bubble_proxy.js';
import {CrLitElement, html} from 'chrome://resources/lit/v3_0/lit.rollup.js';
import {assertDeepEquals, assertEquals, assertFalse, assertThrows, assertTrue} from 'chrome://webui-test/chai_assert.js';
import {TestBrowserProxy} from 'chrome://webui-test/test_browser_proxy.js';
import {isVisible, microtasksFinished} from 'chrome://webui-test/test_util.js';

const TITLE_NATIVE_ID: string = 'kHelpBubbleMixinTestTitleElementId';
const PARAGRAPH_NATIVE_ID: string = 'kHelpBubbleMixinTestParagraphElementId';
const LIST_NATIVE_ID: string = 'kHelpBubbleMixinTestListElementId';
const SPAN_NATIVE_ID: string = 'kHelpBubbleMixinTestSpanElementId';
const LIST_ITEM_NATIVE_ID: string = 'kHelpBubbleMixinTestListItemElementId';
const NESTED_CHILD_NATIVE_ID: string = 'kHelpBubbleMixinTestChildElementId';
const EVENT1_NAME: string = 'kFirstExampleCustomEvent';
const EVENT2_NAME: string = 'kSecondExampleCustomEvent';
const CLOSE_BUTTON_ALT_TEXT: string = 'Close help bubble.';
const BODY_ICON_ALT_TEXT: string = 'Icon help bubble.';

const HelpBubbleMixinTestElementBase = HelpBubbleMixinLit(CrLitElement);

interface HelpBubbleMixinTestElement {
  $: {
    bulletList: HTMLElement,
    container: HTMLElement,
    helpBubble: HelpBubbleElement,
    p1: HTMLElement,
    title: HTMLElement,
  };
}

let titleBubble: HelpBubbleController;
let p1Bubble: HelpBubbleController;
let bulletListBubble: HelpBubbleController;
let spanBubble: HelpBubbleController;
let nestedChildBubble: HelpBubbleController;

// HelpBubbleMixinTestElement
class HelpBubbleMixinTestElement extends HelpBubbleMixinTestElementBase {
  static get is() {
    return 'help-bubble-mixin-test-element';
  }

  override render() {
    return html`
    <div id="container">
      <h1 id="title">This is the title</h1>
      <p id="p1">Some paragraph text</p>
      <ul id="bulletList">
        <li id="list-item">List item 1</li>
        <li>List item 2</li>
      </ul>
      <span style="display: block;">Span text</span>
      <container-element id="container-element"></container-element>
    </div>`;
  }

  override connectedCallback() {
    super.connectedCallback();

    const spanEl = this.shadowRoot!.querySelector('span');
    assertTrue(spanEl !== null, 'connectedCallback: span element exists');

    titleBubble = this.registerHelpBubble(TITLE_NATIVE_ID, '#title')!;
    p1Bubble = this.registerHelpBubble(PARAGRAPH_NATIVE_ID, '#p1')!;
    bulletListBubble = this.registerHelpBubble(LIST_NATIVE_ID, '#bulletList')!;
    spanBubble = this.registerHelpBubble(SPAN_NATIVE_ID, spanEl)!;

    // using different types of selectors to test query mechanism
    nestedChildBubble = this.registerHelpBubble(
        NESTED_CHILD_NATIVE_ID, ['#container-element', '.child-element'])!;
  }
}

customElements.define(
    HelpBubbleMixinTestElement.is, HelpBubbleMixinTestElement);

// HelpBubbleMixinTestContainerElement
export class HelpBubbleMixinTestContainerElement extends CrLitElement {
  static get is() {
    return 'container-element';
  }

  override render() {
    return html`
    <div>
      <div class='child-element'>ABCDE</div>
    </div>`;
  }
}

customElements.define(
    HelpBubbleMixinTestContainerElement.is,
    HelpBubbleMixinTestContainerElement);

class TestHelpBubbleHandler extends TestBrowserProxy implements
    HelpBubbleHandlerInterface {
  // Records the current visibility of all known elements.
  // Simply looking at the call logs can produce extraneous results, as
  // visible=true may be generated multiple times if an element e.g. changes
  // position on the page.
  visibility: Map<string, boolean> = new Map();

  constructor() {
    super([
      'helpBubbleAnchorVisibilityChanged',
      'helpBubbleAnchorActivated',
      'helpBubbleAnchorCustomEvent',
      'helpBubbleButtonPressed',
      'helpBubbleClosed',
    ]);
  }

  helpBubbleAnchorVisibilityChanged(
      nativeIdentifier: string, visible: boolean) {
    this.visibility.set(nativeIdentifier, visible);
    this.methodCalled(
        'helpBubbleAnchorVisibilityChanged', nativeIdentifier, visible);
  }

  helpBubbleAnchorActivated(nativeIdentifier: string) {
    this.methodCalled('helpBubbleAnchorActivated', nativeIdentifier);
  }

  helpBubbleAnchorCustomEvent(nativeIdentifier: string, eventName: string) {
    this.methodCalled(
        'helpBubbleAnchorCustomEvent', nativeIdentifier, eventName);
  }

  helpBubbleButtonPressed(nativeIdentifier: string, button: number) {
    this.methodCalled('helpBubbleButtonPressed', nativeIdentifier, button);
  }

  helpBubbleClosed(nativeIdentifier: string, reason: HelpBubbleClosedReason) {
    this.methodCalled('helpBubbleClosed', nativeIdentifier, reason);
  }
}

class TestHelpBubbleProxy extends TestBrowserProxy implements HelpBubbleProxy {
  private testHandler_ = new TestHelpBubbleHandler();
  private callbackRouter_: HelpBubbleClientCallbackRouter =
      new HelpBubbleClientCallbackRouter();
  private callbackRouterRemote_: HelpBubbleClientRemote;

  constructor() {
    super();

    this.callbackRouterRemote_ =
        this.callbackRouter_.$.bindNewPipeAndPassRemote();
  }

  getHandler(): TestHelpBubbleHandler {
    return this.testHandler_;
  }

  getCallbackRouter(): HelpBubbleClientCallbackRouter {
    return this.callbackRouter_;
  }

  getCallbackRouterRemote(): HelpBubbleClientRemote {
    return this.callbackRouterRemote_;
  }
}

interface WaitForSuccessParams {
  retryIntervalMs: number;
  totalMs: number;
  assertionFn: () => void;
}

suite('CrComponentsHelpBubbleMixinLitTest', () => {
  let testProxy: TestHelpBubbleProxy;
  let container: HelpBubbleMixinTestElement;

  /**
   * Waits for the current frame to render, which queues intersection events,
   * and then waits for the intersection events to propagate to listeners, which
   * triggers visibility messages.
   *
   * This takes a total of two frames. A single frame will cause the layout to
   * be updated, but will not actually propagate the events.
   */
  function waitForVisibilityEvents() {
    return new Promise<void>(resolve => {
      requestAnimationFrame(async () => {
        await sleep(1);
        resolve();
      });
    });
  }

  /**
   * Create a promise that resolves after a given amount of time
   */
  async function sleep(milliseconds: number) {
    return new Promise((res) => {
      setTimeout(res, milliseconds);
    });
  }

  /**
   * Returns the current timestamp in milliseconds since UNIX epoch
   */
  function now() {
    return +new Date();
  }

  /**
   * Try/catch a function for some time, retrying after failures
   *
   * If the callback function succeeds, return early with the total time
   * If the callback always fails, throw the error after the last run
   */
  async function waitForSuccess(params: WaitForSuccessParams):
      Promise<number|null> {
    const startMs = now();
    let lastAttemptMs = startMs;
    let lastError: Error|null = null;
    let attempts = 0;
    while (now() - startMs < params.totalMs) {
      await sleep(params.retryIntervalMs);
      lastAttemptMs = now();
      try {
        params.assertionFn();
        return lastAttemptMs - startMs;
      } catch (e) {
        lastError = e as Error;
      }
      attempts++;
    }
    if (lastError !== null) {
      lastError.message = `[Attempts: ${attempts}, Total time: ${
          lastAttemptMs - startMs}ms]: ${lastError.message}`;
      throw lastError;
    }
    return Infinity;
  }

  setup(() => {
    testProxy = new TestHelpBubbleProxy();
    HelpBubbleProxyImpl.setInstance(testProxy);

    document.body.innerHTML = window.trustedTypes!.emptyHTML;
    container = document.createElement('help-bubble-mixin-test-element') as
        HelpBubbleMixinTestElement;
    document.body.appendChild(container);
    return waitForVisibilityEvents();
  });

  test('help bubble mixin reports bubble closed', () => {
    assertFalse(container.isHelpBubbleShowing());
  });

  const defaultParams: HelpBubbleParams = {
    nativeIdentifier: PARAGRAPH_NATIVE_ID,
    closeButtonAltText: CLOSE_BUTTON_ALT_TEXT,
    position: HelpBubbleArrowPosition.BOTTOM_CENTER,
    bodyText: 'This is a help bubble.',
    bodyIconName: 'lightbulb_outline',
    bodyIconAltText: BODY_ICON_ALT_TEXT,
    buttons: [],
    focusOnShowHint: null,
    titleText: null,
    progress: null,
    timeout: null,
  };

  test('help bubble mixin shows bubble when called directly', () => {
    assertFalse(container.isHelpBubbleShowing());
    assertFalse(container.isHelpBubbleShowingForTesting('p1'));
    container.showHelpBubble(p1Bubble, defaultParams);
    assertTrue(container.isHelpBubbleShowing());
    assertTrue(container.isHelpBubbleShowingForTesting('p1'));
  });

  test(
      'help bubble mixin shows bubble anchored to arbitrary HTMLElment', () => {
        assertFalse(container.isHelpBubbleShowing());
        assertFalse(spanBubble.isBubbleShowing());
        container.showHelpBubble(spanBubble, defaultParams);
        assertTrue(container.isHelpBubbleShowing());
        assertTrue(spanBubble.isBubbleShowing());
      });

  test(
      'help bubble mixin can pierce shadow dom to anchor to deep query', () => {
        const containerElement =
            container.shadowRoot!.querySelector('#container-element');
        let childElement =
            container.shadowRoot!.querySelector('.child-element');

        assertTrue(containerElement !== null, 'container element is found');
        assertTrue(
            childElement === null, 'child element is isolated from container');

        childElement =
            containerElement.shadowRoot!.querySelector('.child-element');
        assertTrue(
            childElement !== null, 'child element is rendered in shadow dom');

        assertTrue(
            childElement === nestedChildBubble.getAnchor(),
            'help bubble anchors to correct element in shadow dom');

        assertFalse(container.isHelpBubbleShowing());
        assertFalse(nestedChildBubble.isBubbleShowing());
        container.showHelpBubble(nestedChildBubble, defaultParams);
        assertTrue(container.isHelpBubbleShowing());
        assertTrue(nestedChildBubble.isBubbleShowing());
      });

  test('help bubble mixin reports not open for other elements', () => {
    // Valid but not open.
    assertFalse(container.isHelpBubbleShowingForTesting('title'));
    // Not valid (and not open).
    assertFalse(container.isHelpBubbleShowingForTesting('foo'));
  });

  test('help bubble mixin hides bubble when called directly', () => {
    container.showHelpBubble(p1Bubble, defaultParams);
    assertTrue(container.hideHelpBubble(p1Bubble.getNativeId()));
    assertFalse(container.isHelpBubbleShowing());
  });

  test('help bubble mixin called directly doesn\'t hide wrong bubble', () => {
    container.showHelpBubble(p1Bubble, defaultParams);
    assertFalse(container.hideHelpBubble(titleBubble.getNativeId()));
    assertTrue(container.isHelpBubbleShowing());
  });

  test('help bubble mixin show and hide multiple bubbles directly', () => {
    container.showHelpBubble(p1Bubble, defaultParams);
    assertTrue(container.isHelpBubbleShowingForTesting('p1'));
    assertFalse(container.isHelpBubbleShowingForTesting('title'));
    assertTrue(container.isHelpBubbleShowing());

    container.showHelpBubble(titleBubble, defaultParams);
    assertTrue(container.isHelpBubbleShowingForTesting('p1'));
    assertTrue(container.isHelpBubbleShowingForTesting('title'));
    assertTrue(container.isHelpBubbleShowing());

    container.hideHelpBubble(p1Bubble.getNativeId());
    assertFalse(container.isHelpBubbleShowingForTesting('p1'));
    assertTrue(container.isHelpBubbleShowingForTesting('title'));
    assertTrue(container.isHelpBubbleShowing());

    container.hideHelpBubble(titleBubble.getNativeId());
    assertFalse(container.isHelpBubbleShowingForTesting('p1'));
    assertFalse(container.isHelpBubbleShowingForTesting('title'));
    assertFalse(container.isHelpBubbleShowing());
  });

  test(
      'help bubble mixin shows help bubble when called via proxy', async () => {
        testProxy.getCallbackRouterRemote().showHelpBubble(defaultParams);
        await microtasksFinished();
        assertTrue(container.isHelpBubbleShowing(), 'a bubble is showing');
        const bubble = container.getHelpBubbleForTesting('p1');
        assertTrue(!!bubble, 'bubble exists');
        assertEquals(
            container.$.p1, bubble.getAnchorElement(),
            'bubble has correct anchor');
        assertTrue(isVisible(bubble), 'bubble is visible');
      });

  test('help bubble mixin uses close button alt text', async () => {
    testProxy.getCallbackRouterRemote().showHelpBubble(defaultParams);
    await microtasksFinished();
    assertTrue(container.isHelpBubbleShowing());
    const bubble = container.getHelpBubbleForTesting('p1')!;
    const closeButton = bubble.shadowRoot!.querySelector<HTMLElement>('#close');
    assertTrue(!!closeButton);
    assertEquals(CLOSE_BUTTON_ALT_TEXT, closeButton.getAttribute('aria-label'));
  });

  test('help bubble mixin uses body icon', async () => {
    testProxy.getCallbackRouterRemote().showHelpBubble(defaultParams);
    await microtasksFinished();
    assertTrue(container.isHelpBubbleShowing());
    const bubble = container.getHelpBubbleForTesting('p1')!;
    assertEquals(bubble.bodyIconName, defaultParams.bodyIconName);
    const bodyIcon = bubble.shadowRoot!.querySelector<HTMLElement>('#bodyIcon');
    assertTrue(!!bodyIcon);
    const ironIcon = bodyIcon.querySelector('cr-icon');
    assertTrue(!!ironIcon);
    assertEquals(`iph:${defaultParams.bodyIconName}`, ironIcon.icon);
  });

  test(
      'help bubble mixin does not use body icon when not defined', async () => {
        const noIconParams = {...defaultParams, bodyIconName: null};
        testProxy.getCallbackRouterRemote().showHelpBubble(noIconParams);
        await microtasksFinished();
        assertTrue(container.isHelpBubbleShowing());
        const bubble = container.getHelpBubbleForTesting('p1')!;
        assertEquals(bubble.bodyIconName, null);
        const bodyIcon =
            bubble.shadowRoot!.querySelector<HTMLElement>('#bodyIcon');
        assertTrue(!!bodyIcon);
        assertTrue(bodyIcon.hidden);
      });

  test(
      'help bubble mixin hides help bubble when called via proxy', async () => {
        testProxy.getCallbackRouterRemote().showHelpBubble(defaultParams);
        await microtasksFinished();
        testProxy.getCallbackRouterRemote().hideHelpBubble(
            defaultParams.nativeIdentifier);
        await microtasksFinished();
        assertFalse(container.isHelpBubbleShowing());
      });

  test(
      'help bubble adds class to element on external help bubble shown',
      async () => {
        testProxy.getCallbackRouterRemote().externalHelpBubbleUpdated(
            TITLE_NATIVE_ID, true);
        await microtasksFinished();
        assertTrue(
            container.$.title.classList.contains(ANCHOR_HIGHLIGHT_CLASS));
        testProxy.getCallbackRouterRemote().externalHelpBubbleUpdated(
            TITLE_NATIVE_ID, false);
        await microtasksFinished();
        assertFalse(
            container.$.title.classList.contains(ANCHOR_HIGHLIGHT_CLASS));
      });

  test(
      'help bubble mixin doesn\'t hide help bubble when called with wrong id',
      async () => {
        testProxy.getCallbackRouterRemote().showHelpBubble(defaultParams);
        await microtasksFinished();
        testProxy.getCallbackRouterRemote().hideHelpBubble(LIST_NATIVE_ID);
        await microtasksFinished();
        assertTrue(container.isHelpBubbleShowing());
      });

  test(
      'help bubble ignores unregistered ID in ShowHelpBubble call',
      async () => {
        const params: HelpBubbleParams = {
          nativeIdentifier: 'This is an unregistered identifier',
          closeButtonAltText: CLOSE_BUTTON_ALT_TEXT,
          bodyIconAltText: BODY_ICON_ALT_TEXT,
          position: HelpBubbleArrowPosition.BOTTOM_CENTER,
          bodyText: 'This is a help bubble.',
          buttons: [],
          bodyIconName: null,
          focusOnShowHint: null,
          progress: null,
          timeout: null,
          titleText: null,
        };

        testProxy.getCallbackRouterRemote().showHelpBubble(params);
        await microtasksFinished();
        assertFalse(container.isHelpBubbleShowing());
      });

  test(
      'help bubble ignores unregistered ID in HideHelpBubble call',
      async () => {
        testProxy.getCallbackRouterRemote().showHelpBubble(defaultParams);
        await microtasksFinished();
        testProxy.getCallbackRouterRemote().hideHelpBubble(
            'This is an unregistered identifier');
        await microtasksFinished();
        assertTrue(container.isHelpBubbleShowing());
      });

  test('help bubble ignores unregistered ID in focus call', async () => {
    testProxy.getCallbackRouterRemote().showHelpBubble(defaultParams);
    await microtasksFinished();
    testProxy.getCallbackRouterRemote().toggleFocusForAccessibility(
        'This is an unregistered identifier');
    await microtasksFinished();
    assertTrue(container.isHelpBubbleShowing());
  });

  test('help bubble mixin sends events on initially visible', async () => {
    await microtasksFinished();
    assertDeepEquals(
        new Map<string, boolean>([
          [TITLE_NATIVE_ID, true],
          [PARAGRAPH_NATIVE_ID, true],
          [LIST_NATIVE_ID, true],
          [SPAN_NATIVE_ID, true],
          [NESTED_CHILD_NATIVE_ID, true],
        ]),
        testProxy.getHandler().visibility);
  });

  test('help bubble mixin sends event on lost visibility', async () => {
    await microtasksFinished();
    container.style.display = 'none';
    await waitForVisibilityEvents();
    assertDeepEquals(
        new Map<string, boolean>([
          [TITLE_NATIVE_ID, false],
          [PARAGRAPH_NATIVE_ID, false],
          [LIST_NATIVE_ID, false],
          [SPAN_NATIVE_ID, false],
          [NESTED_CHILD_NATIVE_ID, false],
        ]),
        testProxy.getHandler().visibility);
  });

  test('help bubble mixin sends event on element activated', async () => {
    container.showHelpBubble(titleBubble, defaultParams);
    container.showHelpBubble(bulletListBubble, defaultParams);
    await microtasksFinished();
    container.notifyHelpBubbleAnchorActivated(bulletListBubble.getNativeId());
    container.notifyHelpBubbleAnchorActivated(titleBubble.getNativeId());
    assertEquals(
        2, testProxy.getHandler().getCallCount('helpBubbleAnchorActivated'));
    assertDeepEquals(
        [LIST_NATIVE_ID, TITLE_NATIVE_ID],
        testProxy.getHandler().getArgs('helpBubbleAnchorActivated'));
  });

  test('help bubble mixin sends custom events', async () => {
    container.showHelpBubble(p1Bubble, defaultParams);
    container.showHelpBubble(titleBubble, defaultParams);
    await microtasksFinished();
    container.notifyHelpBubbleAnchorCustomEvent(
        p1Bubble.getNativeId(), EVENT1_NAME);
    container.notifyHelpBubbleAnchorCustomEvent(
        titleBubble.getNativeId(), EVENT2_NAME);
    assertEquals(
        2, testProxy.getHandler().getCallCount('helpBubbleAnchorCustomEvent'));
    assertDeepEquals(
        [
          [PARAGRAPH_NATIVE_ID, EVENT1_NAME],
          [TITLE_NATIVE_ID, EVENT2_NAME],
        ],
        testProxy.getHandler().getArgs('helpBubbleAnchorCustomEvent'));
  });

  test(
      'help bubble mixin sends event on closed due to anchor losing visibility',
      async () => {
        container.showHelpBubble(p1Bubble, defaultParams);

        // Hiding the container will cause the bubble to be closed.
        container.$.p1.style.display = 'none';
        await waitForVisibilityEvents();

        assertEquals(
            1, testProxy.getHandler().getCallCount('helpBubbleClosed'));
        assertDeepEquals(
            [[PARAGRAPH_NATIVE_ID, HelpBubbleClosedReason.kPageChanged]],
            testProxy.getHandler().getArgs('helpBubbleClosed'));
        assertFalse(container.isHelpBubbleShowing());
      });

  test(
      'help bubble mixin does not send event when non-anchor loses visibility',
      async () => {
        container.showHelpBubble(p1Bubble, defaultParams);

        // This is not the current bubble anchor, so should not send an event.
        container.$.title.style.display = 'none';
        await waitForVisibilityEvents();
        assertEquals(
            0, testProxy.getHandler().getCallCount('helpBubbleClosed'));
        assertTrue(container.isHelpBubbleShowing());
      });

  test('help bubble mixin does not timeout by default', async () => {
    container.showHelpBubble(p1Bubble, defaultParams);

    // This is not the current bubble anchor, so should not send an event.
    container.$.title.style.display = 'none';
    await waitForVisibilityEvents();
    assertEquals(0, testProxy.getHandler().getCallCount('helpBubbleClosed'));
    assertTrue(container.isHelpBubbleShowing());
    await sleep(100);  // 100ms
    assertEquals(0, testProxy.getHandler().getCallCount('helpBubbleClosed'));
    assertTrue(container.isHelpBubbleShowing());
  });

  test('help bubble mixin reshow bubble', async () => {
    testProxy.getCallbackRouterRemote().showHelpBubble(defaultParams);
    await microtasksFinished();
    assertTrue(container.isHelpBubbleShowing());
    testProxy.getCallbackRouterRemote().hideHelpBubble(
        defaultParams.nativeIdentifier);
    await microtasksFinished();
    assertFalse(container.isHelpBubbleShowing());
    testProxy.getCallbackRouterRemote().showHelpBubble(defaultParams);
    await microtasksFinished();
    assertTrue(container.isHelpBubbleShowing());
    const bubble = container.getHelpBubbleForTesting('p1');
    assertTrue(!!bubble);
    assertEquals(container.$.p1, bubble.getAnchorElement());
    assertTrue(isVisible(bubble));
  });

  const paramsWithTitle: HelpBubbleParams = {
    nativeIdentifier: TITLE_NATIVE_ID,
    closeButtonAltText: CLOSE_BUTTON_ALT_TEXT,
    bodyIconAltText: BODY_ICON_ALT_TEXT,
    position: HelpBubbleArrowPosition.TOP_CENTER,
    bodyText: 'This is another help bubble.',
    titleText: 'This is a title',
    buttons: [],
    bodyIconName: null,
    focusOnShowHint: null,
    progress: null,
    timeout: null,
  };

  test('help bubble mixin shows multiple bubbles', async () => {
    testProxy.getCallbackRouterRemote().showHelpBubble(defaultParams);
    await microtasksFinished();
    testProxy.getCallbackRouterRemote().showHelpBubble(paramsWithTitle);
    await microtasksFinished();
    assertTrue(container.isHelpBubbleShowing());
    const bubble1 = container.getHelpBubbleForTesting('title');
    const bubble2 = container.getHelpBubbleForTesting('p1');
    assertTrue(!!bubble1);
    assertTrue(!!bubble2);
    assertEquals(container.$.title, bubble1!.getAnchorElement());
    assertEquals(container.$.p1, bubble2!.getAnchorElement());
    assertTrue(isVisible(bubble1));
    assertTrue(isVisible(bubble2));
  });

  test('help bubble mixin shows bubbles with and without title', async () => {
    testProxy.getCallbackRouterRemote().showHelpBubble(defaultParams);
    await microtasksFinished();
    testProxy.getCallbackRouterRemote().showHelpBubble(paramsWithTitle);
    await microtasksFinished();
    assertTrue(container.isHelpBubbleShowing());
    const titleBubble = container.getHelpBubbleForTesting('title')!;
    const paragraphBubble = container.getHelpBubbleForTesting('p1')!;
    // Testing that setting `titleText` will cause the title to display
    // correctly is present in help_bubble_test.ts, so it is sufficient to
    // verify that the property is set correctly.
    assertEquals('', paragraphBubble.titleText);
    assertEquals(paramsWithTitle.titleText, titleBubble.titleText);
  });

  const paramsWithProgress: HelpBubbleParams = {
    nativeIdentifier: LIST_NATIVE_ID,
    closeButtonAltText: CLOSE_BUTTON_ALT_TEXT,
    bodyIconAltText: BODY_ICON_ALT_TEXT,
    position: HelpBubbleArrowPosition.TOP_CENTER,
    bodyText: 'This is another help bubble.',
    progress: {current: 1, total: 3},
    buttons: [],
    bodyIconName: null,
    focusOnShowHint: null,
    timeout: null,
    titleText: null,
  };

  test(
      'help bubble mixin shows bubbles with and without progress', async () => {
        testProxy.getCallbackRouterRemote().showHelpBubble(defaultParams);
        await microtasksFinished();
        testProxy.getCallbackRouterRemote().showHelpBubble(paramsWithProgress);
        await microtasksFinished();
        assertTrue(container.isHelpBubbleShowing());
        const paragraphBubble = container.getHelpBubbleForTesting('p1')!;
        const progressBubble = container.getHelpBubbleForTesting('bulletList')!;
        // Testing that setting `progress` will cause the progress to display
        // correctly is present in help_bubble_test.ts, so it is sufficient to
        // verify that the property is set correctly.
        assertFalse(!!paragraphBubble.progress);
        assertDeepEquals({current: 1, total: 3}, progressBubble.progress);
      });

  test('help bubble mixin hides multiple bubbles', async () => {
    testProxy.getCallbackRouterRemote().showHelpBubble(defaultParams);
    await microtasksFinished();
    testProxy.getCallbackRouterRemote().showHelpBubble(paramsWithTitle);
    await microtasksFinished();

    testProxy.getCallbackRouterRemote().hideHelpBubble(
        defaultParams.nativeIdentifier);
    await microtasksFinished();
    assertTrue(container.isHelpBubbleShowing());
    assertEquals(
        container.$.title,
        container.getHelpBubbleForTesting('title')?.getAnchorElement());
    assertEquals(null, container.getHelpBubbleForTesting('p1'));

    testProxy.getCallbackRouterRemote().hideHelpBubble(
        paramsWithTitle.nativeIdentifier);
    await microtasksFinished();
    assertFalse(container.isHelpBubbleShowing());
    assertEquals(null, container.getHelpBubbleForTesting('title'));
    assertEquals(null, container.getHelpBubbleForTesting('p1'));
  });

  test('help bubble mixin sends event on closed via button', async () => {
    container.showHelpBubble(p1Bubble, defaultParams);

    // Click the close button.
    container.shadowRoot!.querySelector('help-bubble')!.$.close.click();
    await waitForVisibilityEvents();
    assertEquals(1, testProxy.getHandler().getCallCount('helpBubbleClosed'));
    assertDeepEquals(
        [[PARAGRAPH_NATIVE_ID, HelpBubbleClosedReason.kDismissedByUser]],
        testProxy.getHandler().getArgs('helpBubbleClosed'));
    assertFalse(container.isHelpBubbleShowing());
  });

  const buttonParams: HelpBubbleParams = {
    nativeIdentifier: PARAGRAPH_NATIVE_ID,
    closeButtonAltText: CLOSE_BUTTON_ALT_TEXT,
    bodyIconAltText: BODY_ICON_ALT_TEXT,
    position: HelpBubbleArrowPosition.TOP_CENTER,
    bodyIconName: null,
    bodyText: 'This is another help bubble.',
    titleText: 'This is a title',
    buttons: [
      {
        text: 'button1',
        isDefault: false,
      },
      {
        text: 'button2',
        isDefault: true,
      },
    ],
    focusOnShowHint: null,
    progress: null,
    timeout: null,
  };

  test('help bubble mixin sends action button clicked event', async () => {
    container.showHelpBubble(p1Bubble, buttonParams);
    await microtasksFinished();

    // Click one of the action buttons.
    const button =
        container.shadowRoot!.querySelector('help-bubble')!.getButtonForTesting(
            1);
    assertTrue(!!button);
    button.click();
    await waitForVisibilityEvents();
    assertEquals(
        1, testProxy.getHandler().getCallCount('helpBubbleButtonPressed'));
    assertDeepEquals(
        [[PARAGRAPH_NATIVE_ID, 1]],
        testProxy.getHandler().getArgs('helpBubbleButtonPressed'));
    assertFalse(container.isHelpBubbleShowing());
  });

  const timeoutParams: HelpBubbleParams = {
    nativeIdentifier: PARAGRAPH_NATIVE_ID,
    closeButtonAltText: CLOSE_BUTTON_ALT_TEXT,
    bodyIconName: null,
    bodyIconAltText: BODY_ICON_ALT_TEXT,
    position: HelpBubbleArrowPosition.TOP_CENTER,
    bodyText: 'This is another help bubble.',
    titleText: 'This is a title',
    buttons: [],
    focusOnShowHint: null,
    progress: null,
    timeout: null,
  };

  // It is hard to guarantee the correct timing on various test systems,
  // so the 'before timeout' and 'after timeout' tests are split
  // into 2 separate fixtures

  // Before timeout
  // Use a long timeout to test base state that a timeout will
  // not be accidentally triggered when a timeout is set
  test('help bubble mixin does not immediately timeout', async () => {
    const longTimeoutParams = {
      ...timeoutParams,
      timeout: {
        microseconds: BigInt(10 * 1000 * 1000),  // 10s
      },
    };

    container.showHelpBubble(p1Bubble, longTimeoutParams);
    await microtasksFinished();
    assertEquals(
        0, testProxy.getHandler().getCallCount('helpBubbleClosed'),
        'helpBubbleClosed has not be called');
    assertTrue(container.isHelpBubbleShowing());
  });

  // After timeout
  // Use a short timeout and a retry loop to
  test('help bubble mixin sends timeout event', async () => {
    const timeoutMs = 100;
    const shortTimeoutParams = {
      ...timeoutParams,
      timeout: {
        microseconds: BigInt(timeoutMs * 1000),  // 100ms
      },
    };

    container.showHelpBubble(p1Bubble, shortTimeoutParams);
    await microtasksFinished();
    await waitForSuccess({
      retryIntervalMs: 50,
      totalMs: 1500,
      assertionFn: () => assertEquals(
          1, testProxy.getHandler().getCallCount('helpBubbleClosed'),
          'helpBubbleClosed has been called'),
    }) as number;
    assertDeepEquals(
        [[PARAGRAPH_NATIVE_ID, HelpBubbleClosedReason.kTimedOut]],
        testProxy.getHandler().getArgs('helpBubbleClosed'),
        'helpBubbleClosed is called with correct arguments');
    assertFalse(container.isHelpBubbleShowing(), 'no bubbles are showing');
  });

  test('help bubble mixin can unregister', () => {
    let listItemBubble =
        container.registerHelpBubble(LIST_ITEM_NATIVE_ID, '#bulletList');
    assertTrue(listItemBubble !== null, 'help bubble is registered');
    assertTrue(
        container.canShowHelpBubble(listItemBubble!),
        'help bubble can be shown');

    // re-register when help bubble is not showing
    listItemBubble =
        container.registerHelpBubble(LIST_ITEM_NATIVE_ID, '#list-item');
    assertTrue(
        listItemBubble !== null,
        'help bubble can be re-registered with same nativeId');
    assertTrue(
        container.canShowHelpBubble(listItemBubble!),
        'help bubble can be shown after re-registering');

    // un-register directly when help bubble is not showing
    container.unregisterHelpBubble(LIST_ITEM_NATIVE_ID);
    assertFalse(
        container.canShowHelpBubble(listItemBubble!),
        'help bubble cannot be shown');
    // unregisterHelpBubble clears out the nativeIds
    assertThrows(
        () => container.showHelpBubble(listItemBubble!, defaultParams),
        'Can\'t show help bubble',
    );
  });

  test('help bubble mixin can unregister when bubble is showing', () => {
    const listItemBubble =
        container.registerHelpBubble(LIST_ITEM_NATIVE_ID, '#list-item');
    assertTrue(listItemBubble !== null, 'help bubble is registered');
    assertTrue(
        container.canShowHelpBubble(listItemBubble!),
        'help bubble can be shown');
    assertFalse(container.isHelpBubbleShowing());
    assertFalse(container.isHelpBubbleShowingForTesting('list-item'));

    container.showHelpBubble(listItemBubble!, defaultParams);
    assertTrue(container.isHelpBubbleShowing());
    assertTrue(container.isHelpBubbleShowingForTesting('list-item'));

    // re-register when help bubble is shown
    const result =
        container.registerHelpBubble(LIST_ITEM_NATIVE_ID, '#list-item');
    assertTrue(
        result === null, 'registerHelpBubble fails when help bubble is shown');
    assertTrue(
        container.isHelpBubbleShowing(),
        're-registering does not hide help bubble');
    assertTrue(container.isHelpBubbleShowingForTesting('list-item'));

    // unregister directly when help bubble is shown
    container.unregisterHelpBubble(LIST_ITEM_NATIVE_ID);
    assertFalse(
        container.isHelpBubbleShowing(), 'unregister hides help bubble');
    assertFalse(container.isHelpBubbleShowingForTesting('list-item'));
  });
});