chromium/chrome/test/data/webui/cr_elements/cr_lit_element_test.ts

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

import {getTrustedHTML} from 'chrome://resources/js/static_types.js';
import {CrLitElement, html} from 'chrome://resources/lit/v3_0/lit.rollup.js';
import {html as polymerHtml, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {assertDeepEquals, assertEquals, assertFalse, assertNotEquals, assertNotReached, assertNull, assertThrows, assertTrue} from 'chrome://webui-test/chai_assert.js';
import {eventToPromise} from 'chrome://webui-test/test_util.js';

type Constructor<T> = new (...args: any[]) => T;

interface LifecycleCallbackTrackerMixinInterface {
  lifecycleCallbacks: string[];
}

const LifecycleCallbackTrackerMixin =
    <T extends Constructor<CrLitElement>>(superClass: T): T&
    Constructor<LifecycleCallbackTrackerMixinInterface> => {
      class LifecycleCallbackTrackerMixin extends superClass {
        lifecycleCallbacks: string[] = [];

        override connectedCallback() {
          this.lifecycleCallbacks.push('connectedCallback');
          super.connectedCallback();
        }

        override disconnectedCallback() {
          this.lifecycleCallbacks.push('disconnectedCallback');
          super.disconnectedCallback();
        }

        override performUpdate() {
          this.lifecycleCallbacks.push('performUpdate');
          super.performUpdate();
        }
      }

      return LifecycleCallbackTrackerMixin;
    };

const CrDummyLitElementBase = LifecycleCallbackTrackerMixin(CrLitElement);

interface CrDummyLitElement {
  $: {
    foo: HTMLElement,
    bar: HTMLElement,
  };
}

class CrDummyLitElement extends CrDummyLitElementBase {
  static get is() {
    return 'cr-dummy-lit' as const;
  }

  override render() {
    return html`
      <div id="foo">Hello Foo</div>
      <div id="bar">Hello Bar</div>
    `;
  }
}

customElements.define(CrDummyLitElement.is, CrDummyLitElement);

class CrDummyPropertiesWithNotifyElement extends CrLitElement {
  static get is() {
    return 'cr-dummy-properties-with-notify' as const;
  }

  static override get properties() {
    return {
      prop1: {
        type: Boolean,
        notify: true,
      },

      prop2: {
        type: Boolean,
        notify: false,
      },

      prop3: {type: Boolean},

      propFour: {
        type: Boolean,
        notify: true,
      },

      prop5: {
        type: Boolean,
        notify: true,
      },
    };
  }

  prop1: boolean = false;
  prop2: boolean = false;
  prop3: boolean = false;
  propFour: boolean = false;
  prop5: boolean|undefined = false;
}

customElements.define(
    CrDummyPropertiesWithNotifyElement.is, CrDummyPropertiesWithNotifyElement);

declare global {
  interface HTMLElementTagNameMap {
    [CrDummyLitElement.is]: CrDummyLitElement;
    [CrDummyPropertiesWithNotifyElement.is]: CrDummyPropertiesWithNotifyElement;
  }
}

suite('CrLitElement', function() {
  setup(function() {
    document.body.innerHTML = window.trustedTypes!.emptyHTML;
  });

  test('ForcedRendering_ConnectedCallback', function() {
    const element = document.createElement('cr-dummy-lit');
    assertNull(element.shadowRoot);
    document.body.appendChild(element);

    // Test that the order of lifecycle callbacks is as expected.
    assertDeepEquals(
        ['connectedCallback', 'performUpdate'], element.lifecycleCallbacks);

    // Purposefully *not* calling `await element.updateComplete` here, to ensure
    // that initial rendering is synchronous when subclassing CrLitElement.

    assertNotEquals(null, element.shadowRoot);
  });

  // Called by cr-polymer-wrapper's connectedCallback() below. Exposed as a hook
  // to allow testing different cases.
  let polymerWrapperCallback: (e: CrDummyLitElement) => void = (_e) => {};

  // Defines two elements, cr-dom-if-polymer and cr-polymer-wrapper which are
  // used in a couple test cases.
  function defineForcedRenderingTestElements() {
    if (customElements.get('cr-polymer-wrapper')) {
      // Don't re-register the same elements, since this would lead to a runtime
      // error.
      return;
    }

    class CrPolymerWrapperElement extends PolymerElement {
      static get is() {
        return 'cr-polymer-wrapper';
      }

      static get template() {
        return polymerHtml`<cr-dummy-lit></cr-dummy-lit>`;
      }

      override connectedCallback() {
        super.connectedCallback();

        const litChild = this.shadowRoot!.querySelector('cr-dummy-lit');
        assertTrue(!!litChild);

        // Ensure that the problem is happening.
        assertNull(litChild.shadowRoot);
        assertDeepEquals([], litChild.lifecycleCallbacks);

        // Trigger the callback that is supposed to cause a forced-rendering.
        polymerWrapperCallback(litChild);

        // Check that the forced-rendering indeed happened.
        assertTrue(!!litChild.shadowRoot);

        // Check that 'performUpdate' was called, even though
        // 'connectedCallback' has not fired yet.
        assertDeepEquals(['performUpdate'], litChild.lifecycleCallbacks);

        litChild.updateComplete.then(() => {
          // Check that 'connectedCallback' and 'performUpdate' are called
          // exactly once some time later by Lit itself.
          assertDeepEquals(
              ['performUpdate', 'connectedCallback', 'performUpdate'],
              litChild.lifecycleCallbacks);
          this.dispatchEvent(new CustomEvent(
              'test-finished', {bubbles: true, composed: true}));
        });
      }
    }

    customElements.define(CrPolymerWrapperElement.is, CrPolymerWrapperElement);

    class CrDomIfPolymerElement extends PolymerElement {
      static get is() {
        return 'cr-dom-if-polymer';
      }

      static get template() {
        return polymerHtml`
          <template is="dom-if" if="[[show]]">
            <cr-polymer-wrapper></cr-polymer-wrapper>
          </template>
        `;
      }

      static get properties() {
        return {
          show: Boolean,
        };
      }

      show: boolean = false;

      override connectedCallback() {
        super.connectedCallback();
        this.show = true;
      }
    }

    customElements.define(CrDomIfPolymerElement.is, CrDomIfPolymerElement);
  }

  // Test an odd case where a CrLitElement is connected to the DOM but its
  // connectedCallback() method has not fired yet, which happens when the
  // following pattern is encountered:
  // dom-if > parent PolymerElement -> child CrLitElement
  // See CrLitElement definition for more details. Ensure that `shadowRoot` is
  // force-rendered as part of accessing the $ dictionary.
  test('ForcedRendering_BeforeConnectedCallback_DollarSign', function() {
    defineForcedRenderingTestElements();
    polymerWrapperCallback = (litChild: CrDummyLitElement) => {
      // Access the $ dictionary, to test that it causes the
      // `shadowRoot` to be force-rendered.
      assertTrue(!!litChild.$.foo);
    };

    const whenDone = eventToPromise('test-finished', document.body);
    document.body.innerHTML = getTrustedHTML`
      <cr-dom-if-polymer></cr-dom-if-polymer>
    `;
    return whenDone;
  });

  // Test an odd case where a CrLitElement is connected to the DOM but its
  // connectedCallback() method has not fired yet, which happens when the
  // following pattern is encountered:
  // dom-if > parent PolymerElement -> child CrLitElement
  // See CrLitElement definition for more details. Ensure that `shadowRoot` is
  // force-rendered as part of calling focus().
  test('ForcedRendering_BeforeConnectedCallback_Focus', function() {
    defineForcedRenderingTestElements();
    polymerWrapperCallback = (litChild: CrDummyLitElement) => {
      // Call focus() to test that whether causes the `shadowRoot` to be
      // force-rendered.
      litChild.focus();
    };

    const whenDone = eventToPromise('test-finished', document.body);
    document.body.innerHTML = getTrustedHTML`
      <cr-dom-if-polymer></cr-dom-if-polymer>
    `;
    return whenDone;
  });

  test('DollarSign_ErrorWhenNotConnectedOnce', function() {
    const element = document.createElement('cr-dummy-lit');
    assertDeepEquals([], element.lifecycleCallbacks);

    assertThrows(function() {
      element.$.foo;
      assertNotReached('Previous statement should have thrown an exception');
    }, 'CrLitElement CR-DUMMY-LIT $ dictionary accessed before element is connected at least once.');

    assertDeepEquals([], element.lifecycleCallbacks);
  });

  test('DollarSign_WorksAfterDisconnected', function() {
    const element = document.createElement('cr-dummy-lit');
    assertDeepEquals([], element.lifecycleCallbacks);
    document.body.appendChild(element);

    assertDeepEquals(
        ['connectedCallback', 'performUpdate'], element.lifecycleCallbacks);

    element.remove();
    assertDeepEquals(
        ['connectedCallback', 'performUpdate', 'disconnectedCallback'],
        element.lifecycleCallbacks);
    assertFalse(element.isConnected);
    assertNotEquals(null, element.$.foo);
  });

  test('DollarSign_WorksWhenConnected', function() {
    const element = document.createElement('cr-dummy-lit');
    document.body.appendChild(element);

    assertDeepEquals(
        ['connectedCallback', 'performUpdate'], element.lifecycleCallbacks);

    // Purposefully *not* calling `await element.updateComplete` here, to ensure
    // that initial rendering is synchronous when subclassing CrLitElement.

    assertTrue(!!element.$.foo);
    assertEquals('Hello Foo', element.$.foo.textContent);
    assertTrue(!!element.$.bar);
    assertEquals('Hello Bar', element.$.bar.textContent);

    // Test again lifecycle callbacks to ensure that performUpdate() has not
    // been called a second time as part of accessing the $ dictionary.
    assertDeepEquals(
        ['connectedCallback', 'performUpdate'], element.lifecycleCallbacks);
  });

  // Test that properties are initialized correctly from attributes.
  test('PropertiesAttributesNameMapping', function() {
    class CrDummyAttributesLitElement extends CrLitElement {
      static get is() {
        return 'cr-dummy-attributes-lit';
      }

      static override get properties() {
        return {
          fooBarBoolean: {type: Boolean},
          fooBarString: {type: String},

          fooBarStringCustom: {
            attribute: 'foobarstringcustom',
            type: String,
          },
        };
      }

      fooBarBoolean: boolean = false;
      fooBarString: string = 'hello';
      fooBarStringCustom: string = 'hola';
    }

    customElements.define(
        CrDummyAttributesLitElement.is, CrDummyAttributesLitElement);

    document.body.innerHTML = getTrustedHTML`
      <cr-dummy-attributes-lit foo-bar-boolean foo-bar-string="world"
          foobarstringcustom="custom">
      </cr-dummy-attributes-lit>
    `;

    const element = document.body.querySelector<CrDummyAttributesLitElement>(
        'cr-dummy-attributes-lit');
    assertTrue(!!element);
    assertTrue(element.fooBarBoolean);
    assertEquals('world', element.fooBarString);
    assertEquals('custom', element.fooBarStringCustom);
  });

  test('PropertiesWithNotify', async function() {
    const element = document.createElement('cr-dummy-properties-with-notify');

    function unexpectedEventListener(e: Event) {
      assertNotReached(`Unexpected event caught: ${e.type}`);
    }

    // Ensure that properties without 'notify: true' don't trigger events.
    element.addEventListener('prop2-changed', unexpectedEventListener);
    element.addEventListener('prop3-changed', unexpectedEventListener);

    // Ensure that properties with 'notify: true' that
    //   1) have a non-undefined initial value AND
    //   2) are changed back to undefined before the element is connected
    // also don't trigger updates.
    element.addEventListener('prop5-changed', unexpectedEventListener);
    element.prop5 = undefined;

    // Ensure that properties with 'notify: true' trigger events.

    // Case1: An event should be fired after the element is initialized to
    // propagate the initial value of the reactive property.
    const whenFired1 = Promise.all([
      eventToPromise('prop1-changed', element),
      eventToPromise('prop-four-changed', element),
    ]) as Promise<Array<CustomEvent<{value: boolean}>>>;

    document.body.appendChild(element);

    const events = await whenFired1;
    for (const event of events) {
      assertTrue(event.bubbles);
      assertTrue(event.composed);
      assertDeepEquals({value: false}, event.detail);
    }

    // Case2: An event should be fired whenever the property changes.
    let whenFired2 = eventToPromise('prop1-changed', element);
    element.prop1 = true;
    let event = await whenFired2;
    assertTrue(event.bubbles);
    assertTrue(event.composed);
    assertDeepEquals({value: true}, event.detail);

    whenFired2 = eventToPromise('prop-four-changed', element);
    element.propFour = true;
    event = await whenFired2;
    assertTrue(event.bubbles);
    assertTrue(event.composed);
    assertDeepEquals({value: true}, event.detail);
  });

  // Test that a Lit child with 'notify: true' properties works with a Polymer
  // parent that uses 2-way bindings for that property.
  test('PropertiesWithNotifyTwoWayBinding', async function() {
    class CrPolymerWrapperElement extends PolymerElement {
      static get is() {
        return 'cr-polymer-wrapper-with-two-way-binding';
      }

      static get template() {
        return polymerHtml`
            <cr-dummy-properties-with-notify prop1="{{myProp}}">
            </cr-dummy-properties-with-notify>`;
      }

      static get properties() {
        return {
          myProp: Boolean,
        };
      }

      myProp: boolean = false;
    }

    customElements.define(CrPolymerWrapperElement.is, CrPolymerWrapperElement);

    const parent =
        document.createElement('cr-polymer-wrapper-with-two-way-binding') as
        CrPolymerWrapperElement;
    document.body.appendChild(parent);

    const child =
        parent.shadowRoot!.querySelector('cr-dummy-properties-with-notify');
    assertTrue(!!child);
    assertFalse(child.prop1);
    assertFalse(parent.myProp);

    // Case1: Changes in child update parent's property.
    let whenFired = eventToPromise('prop1-changed', child);
    child.prop1 = true;
    await whenFired;
    assertTrue(parent.myProp);

    // Case2: Changes in parent update child's property.
    whenFired = eventToPromise('prop1-changed', child);
    parent.myProp = false;
    await whenFired;
    assertFalse(child.prop1);
  });

  test('Fire', async function() {
    const element = document.createElement('cr-dummy-lit');
    document.body.appendChild(element);

    const dummyEventName = 'dummy-event';
    const dummyPayload = 'hello dummy';

    const whenFired = eventToPromise(dummyEventName, element);
    element.fire(dummyEventName, dummyPayload);

    const event = await whenFired;
    assertTrue(event.bubbles);
    assertTrue(event.composed);
    assertEquals(dummyPayload, event.detail);
  });
});