/* Any copyright is dedicated to the Public Domain. http://creativecommons.org/publicdomain/zero/1.0/ */ "use strict"; const { TestUtils } = ChromeUtils.importESModule( "resource://testing-common/TestUtils.sys.mjs" ); const { BrowserTestUtils } = ChromeUtils.importESModule( "resource://testing-common/BrowserTestUtils.sys.mjs" ); /** * A collection of helper functions for writing tests using Lit. These methods * will almost always need to be used in a specific order to first import Lit, * then provide template code, then render the template code: * * let testHelpers = new LitTestHelpers(); * * add_setup(async function setup() { * let { html } = await testHelpers.setupLit(); * let templateFn = (attrs, children) => html` * ${children} * `; * testHelpers.setupTests({ templateFn }); * }); * * add_task(async function testMyElement() { * let renderTarget = await testHelpers.renderTemplate(); * let myElement = renderTarget.firstElementChild; * ... * }); */ class LitTestHelpers { /** * Imports the Lit library and exposes it for use in test helpers. * * @returns {object} The Lit library for use in consuming tests. */ async setupLit() { let lit = await import("chrome://global/content/vendor/lit.all.mjs"); ({ html: this.html, render: this.render } = lit); this.SpreadDirective = class extends lit.Directive { render() { return lit.nothing; } update(part, [attrs]) { for (let [key, value] of Object.entries(attrs)) { part.element.setAttribute(key, value); } return lit.noChange; } }; this.PropertySpreadDirective = class extends lit.Directive { render() { return lit.nothing; } update(part, [attrs]) { for (let [key, value] of Object.entries(attrs)) { part[key] = value; } return lit.noChange; } }; this.spread = lit.directive(this.SpreadDirective); this.propertySpread = lit.directive(this.PropertySpreadDirective); return lit; } /** * Sets up data used in test helpers and creates the DOM element that test * templates get rendered into. * * @param {object} [templateFn] - Template function to render the element and * any associated markup. When called it will receive two positional * argument `attrs` which should be applied to the element under test, and * `children` which should be added as a child of the element under test. * e.g. `(attrs, children) => ${children}` */ setupTests({ templateFn }) { this.templateFn = (args = {}, children) => templateFn(this.spread(args), children); this.renderTarget = document.createElement("div"); document.body.append(this.renderTarget); } /** * Renders a template for use in tests. * * @param {object} [template] - Optional template to render specific markup. * @returns {object} DOM node containing the rendered template elements. */ async renderTemplate(template = this.templateFn()) { // Clear out any previously rendered DOM content. this.render(this.html``, this.renderTarget); this.render(template, this.renderTarget); await this.renderTarget.firstElementChild.updateComplete; return this.renderTarget; } } /** * Helper and test functions that are useful when writing tests for reusable * input elements such as moz-checkbox, moz-select, moz-radio, etc. */ class InputTestHelpers extends LitTestHelpers { /** * Sets up helpers that can be used to verify the events emitted from custom * input elements. * * @returns {object} Event test helper functions. */ getInputEventHelpers() { let seenEvents = []; let { activatedProperty } = this; function trackEvent(event) { let reactiveProps = event.target.constructor?.properties; seenEvents.push({ type: event.type, value: event.currentTarget.value, localName: event.currentTarget.localName, ...(reactiveProps?.hasOwnProperty(activatedProperty) && { [activatedProperty]: event.target[activatedProperty], }), }); } function verifyEvents(expectedEvents) { is( seenEvents.length, expectedEvents.length, "Input elements emit the expected number of events." ); expectedEvents.forEach((eventInfo, index) => { let seenEventInfo = seenEvents[index]; is(seenEventInfo.type, eventInfo.type, "Event type is correct."); is( seenEventInfo.value, eventInfo.value, "Event target value is correct." ); is( seenEventInfo.localName, eventInfo.localName, "Event is emitted from the correct element." ); if (activatedProperty) { is( seenEventInfo[activatedProperty], eventInfo[activatedProperty], `Event ${activatedProperty} state is correct.` ); } }); // Reset seen events after verifying. seenEvents = []; } return { seenEvents, trackEvent, verifyEvents }; } /** * Runs through a collection of tests of properties that should be common to * all reusable moz- input elements. * * @param {string} elementName - HTML tag of the element under test. */ async testCommonInputProperties(elementName) { await this.verifyLabel(elementName); await this.verifyName(elementName); await this.verifyValue(elementName); await this.verifyIcon(elementName); await this.verifyDisabled(elementName); await this.verifyDescription(elementName); await this.verifySupportPage(elementName); await this.verifyAccesskey(elementName); await this.verifyNoWhitespace(elementName); if (this.activatedProperty) { await this.verifyActivated(elementName); await this.verifyNestedFields(elementName); } } /** * Verifies input element label property is settable and dynamic. * * @param {string} selector - HTML tag of the element under test. */ async verifyLabel(selector) { const INITIAL_LABEL = "This is a label."; const NEW_LABEL = "Testing..."; let labelTemplate = this.templateFn({ value: "label", label: INITIAL_LABEL, }); let renderTarget = await this.renderTemplate(labelTemplate); let firstInput = renderTarget.querySelector(selector); is( firstInput.labelEl.innerText.trim(), INITIAL_LABEL, "Input label text is set." ); firstInput.label = NEW_LABEL; await firstInput.updateComplete; is( firstInput.labelEl.innerText.trim(), NEW_LABEL, "Input label text is updated." ); } /** * Verifies input element name property is settable and dynamic. * * @param {string} selector - HTML tag of the element under test. */ async verifyName(selector) { const INITIAL_NAME = "name"; const NEW_NAME = "new-name"; let renderTarget = await this.renderTemplate(); let firstInput = renderTarget.querySelector(selector); firstInput.name = INITIAL_NAME; await firstInput.updateComplete; is(firstInput.inputEl.name, INITIAL_NAME, "Input name is set."); firstInput.name = NEW_NAME; await firstInput.updateComplete; is(firstInput.inputEl.name, NEW_NAME, "Input name is updated."); } /** * Verifies input element value property is settable and dynamic. * * @param {string} selector - HTML tag of the element under test. */ async verifyValue(selector) { const INITIAL_VALUE = "value"; const NEW_VALUE = "new value"; let valueTemplate = this.templateFn({ label: "Testing value", value: INITIAL_VALUE, }); let renderTarget = await this.renderTemplate(valueTemplate); let firstInput = renderTarget.querySelector(selector); is(firstInput.inputEl.value, INITIAL_VALUE, "Input value is set."); firstInput.value = NEW_VALUE; await firstInput.updateComplete; is(firstInput.inputEl.value, NEW_VALUE, "Input value is updated."); } /** * Verifies input element can display and icon. * * @param {string} selector - HTML tag of the element under test. */ async verifyIcon(selector) { const ICON_SRC = "chrome://global/skin/icons/edit-copy.svg"; let iconTemplate = this.templateFn({ value: "icon", label: "Testing icon", iconsrc: ICON_SRC, }); let renderTarget = await this.renderTemplate(iconTemplate); let firstInput = renderTarget.querySelector(selector); ok(firstInput.icon, "Input displays an icon."); is( firstInput.icon.getAttribute("src"), "chrome://global/skin/icons/edit-copy.svg", "Input icon uses the expected source." ); firstInput.iconSrc = null; await firstInput.updateComplete; ok(!firstInput.icon, "Input icon can be removed."); } /** * Verifies it is possible to disable and re-enable input element. * * @param {string} selector - HTML tag of the element under test. */ async verifyDisabled(selector) { let renderTarget = await this.renderTemplate(); let firstInput = renderTarget.querySelector(selector); ok(!firstInput.disabled, "Input is enabled on initial render."); firstInput.disabled = true; await firstInput.updateComplete; ok(firstInput.disabled, "Input is disabled."); ok(firstInput.inputEl.disabled, "Disabled state is propagated."); firstInput.disabled = false; await firstInput.updateComplete; ok(!firstInput.disabled, "Input can be re-enabled."); ok(!firstInput.inputEl.disabled, "Disabled state is propagated."); } /** * Verifies different methods for providing a description to the input element. * * @param {string} selector - HTML tag of the element under test. */ async verifyDescription(selector) { const ATTR_DESCRIPTION = "This description is set via an attribute."; const SLOTTED_DESCRIPTION = "This description is set via a slot."; let templatesArgs = [ [{ description: ATTR_DESCRIPTION }], [{}, this.html`${SLOTTED_DESCRIPTION}`], [ { description: ATTR_DESCRIPTION }, this.html`${SLOTTED_DESCRIPTION}`, ], ]; let renderTarget = await this.renderTemplate( templatesArgs.map(args => this.templateFn(...args)) ); let [firstInput, secondInput, thirdInput] = renderTarget.querySelectorAll(selector); is( firstInput.inputEl.getAttribute("aria-describedby"), "description", "Input is described by the description element." ); is( firstInput.descriptionEl.innerText, ATTR_DESCRIPTION, "Description element has the expected text when set via an attribute." ); let slottedText = secondInput.descriptionEl .querySelector("slot[name=description]") .assignedElements()[0].innerText; is( secondInput.inputEl.getAttribute("aria-describedby"), "description", "Input is described by the description element." ); is( slottedText, SLOTTED_DESCRIPTION, "Description element has the expected text when set via a slot." ); is( thirdInput.inputEl.getAttribute("aria-describedby"), "description", "Input is described by the description element." ); is( thirdInput.descriptionEl.innerText, ATTR_DESCRIPTION, "Attribute text takes precedence over slotted text." ); } /** * Verifies different methods for providing a support link for the input element. * * @param {string} selector - HTML tag of the element under test. */ async verifySupportPage(selector) { const LEARN_MORE_TEXT = "Learn more"; const CUSTOM_TEXT = "Help me!"; let templatesArgs = [ [{ "support-page": "test-page", label: "A label" }], [ { label: "A label" }, this.html`Help me!`, ], ]; let renderTarget = await this.renderTemplate( templatesArgs.map(args => this.templateFn(...args)) ); let [firstInput, secondInput] = renderTarget.querySelectorAll(selector); let getSupportLink = () => firstInput.shadowRoot.querySelector("a[is=moz-support-link]"); let supportLink = getSupportLink(); await BrowserTestUtils.waitForMutationCondition( supportLink, { childList: true }, () => supportLink.textContent.trim() ); ok( supportLink, "Support link is rendered when supportPage attribute is set." ); ok( supportLink.href.includes("test-page"), "Support link href points to the expected SUMO page." ); is( supportLink.innerText, LEARN_MORE_TEXT, "Support link uses the default label text." ); is( supportLink.previousElementSibling.localName, "label", "Support link is rendered next to the label by default." ); firstInput.description = "some description text"; await firstInput.updateComplete; is( getSupportLink().previousElementSibling.id, "description", "Support link is rendered next to the description if a description is present." ); if (firstInput.isInlineLayout) { ok( !getSupportLink().getAttribute("aria-describedby"), "aria-describedby is not set on the support link." ); } else { is( getSupportLink().getAttribute("aria-describedby"), "label description", "Support link is described by the label and description elements." ); } let getSlottedSupportLink = () => secondInput.shadowRoot .querySelector("slot[name=support-link]") .assignedElements()[0]; let slottedSupportLink = getSlottedSupportLink(); ok( slottedSupportLink, "Links can also be rendered using the support-link slot." ); ok( slottedSupportLink.href.includes("www.example.com"), "Slotted link uses the expected url." ); is( slottedSupportLink.innerText, CUSTOM_TEXT, "Slotted link uses non-default label text." ); is( slottedSupportLink.assignedSlot.previousElementSibling.localName, "label", "Slotted support link is rendered next to the label by default." ); let slottedDescriptionPresent = BrowserTestUtils.waitForMutationCondition( secondInput, { childList: true, subtree: true }, () => secondInput.descriptionEl .querySelector("slot[name='description']") .assignedElements().length ); let description = document.createElement("span"); description.textContent = "I'm a slotted description."; description.slot = "description"; secondInput.append(description); await slottedDescriptionPresent; is( getSlottedSupportLink().assignedSlot.previousElementSibling.id, "description", "Support link is rendered next to the slotted description if a slotted description is present." ); } /** * Verifies the accesskey behavior of the input element. * * @param {string} selector - HTML tag of the element under test. */ async verifyAccesskey(selector) { const UNIQUE_ACCESS_KEY = "t"; const SHARED_ACCESS_KEY = "d"; let { activatedProperty } = this; let attrs = [ { value: "first", label: "First", accesskey: UNIQUE_ACCESS_KEY }, { value: "second", label: "Second", accesskey: SHARED_ACCESS_KEY }, { value: "third", label: "Third", accesskey: SHARED_ACCESS_KEY }, ]; let accesskeyTemplate = this.html`${attrs.map(a => this.templateFn(a))}`; let renderTarget = await this.renderTemplate(accesskeyTemplate); let [firstInput, secondInput, thirdInput] = renderTarget.querySelectorAll(selector); // Validate that activating a unique accesskey focuses and checks the input. firstInput.blur(); isnot(document.activeElement, firstInput, "First input is not focused."); isnot( firstInput.shadowRoot.activeElement, firstInput.inputEl, "Input element is not focused." ); if (activatedProperty) { ok(!firstInput[activatedProperty], `Input is not ${activatedProperty}.`); } synthesizeKey( UNIQUE_ACCESS_KEY, navigator.platform.includes("Mac") ? { altKey: true, ctrlKey: true } : { altKey: true, shiftKey: true } ); is( document.activeElement, firstInput, "Input receives focus after accesskey is pressed." ); is( firstInput.shadowRoot.activeElement, firstInput.inputEl, "Input element is focused after accesskey is pressed." ); if (activatedProperty) { ok( firstInput[activatedProperty], `Input is ${activatedProperty} after accesskey is pressed.` ); } // Validate that activating a shared accesskey toggles focus between inputs. synthesizeKey( SHARED_ACCESS_KEY, navigator.platform.includes("Mac") ? { altKey: true, ctrlKey: true } : { altKey: true, shiftKey: true } ); is( document.activeElement, secondInput, "Focus moves to the input with the shared accesskey." ); if (activatedProperty) { ok( !secondInput[activatedProperty], `Second input is not ${activatedProperty}.` ); } synthesizeKey( SHARED_ACCESS_KEY, navigator.platform.includes("Mac") ? { altKey: true, ctrlKey: true } : { altKey: true, shiftKey: true } ); is( document.activeElement, thirdInput, "Focus cycles between inputs with the same accesskey." ); if (activatedProperty) { ok( !thirdInput[activatedProperty], `Third input is not ${activatedProperty}.` ); } } /** * Verifies the activated state of the input element. * * @param {string} selector - HTML tag of the element under test. */ async verifyActivated(selector) { let renderTarget = await this.renderTemplate(); let firstInput = renderTarget.querySelector(selector); let { activatedProperty } = this; ok( !firstInput.inputEl[activatedProperty] && !firstInput[activatedProperty], `Input is not ${activatedProperty} on initial render.` ); firstInput[activatedProperty] = true; await firstInput.updateComplete; ok(firstInput[activatedProperty], `Input is ${activatedProperty}.`); ok( firstInput.inputEl[activatedProperty] || firstInput.inputEl.getAttribute(`aria-${activatedProperty}`) == "true", `${activatedProperty} state is propagated.` ); // Reset state so that the radio input doesn't // give a false negative firstInput[activatedProperty] = false; await firstInput.updateComplete; synthesizeMouseAtCenter(firstInput.inputEl, {}); await firstInput.updateComplete; ok( firstInput[activatedProperty], `Input is ${activatedProperty} via mouse.` ); ok( firstInput.inputEl[activatedProperty] || firstInput.inputEl.getAttribute(`aria-${activatedProperty}`) == "true", `${activatedProperty} state is propagated.` ); } /** * Verifies that whitespace isn't getting added via different parts of the * template as it will be visible in the rendered markup. * * @param {string} selector - HTML tag of the element under test. */ async verifyNoWhitespace(selector) { let whitespaceTemplate = this.templateFn({ label: "label", "support-page": "test", "icon-src": "chrome://global/skin/icons/edit-copy.svg", }); let renderTarget = await this.renderTemplate(whitespaceTemplate); let firstInput = renderTarget.querySelector(selector); if (!firstInput.isInlineLayout) { return; } function isWhitespaceTextNode(node) { return node.nodeType == Node.TEXT_NODE && !/[^\s]/.exec(node.nodeValue); } ok( !isWhitespaceTextNode(firstInput.inputEl.previousSibling), "Input element is not preceded by whitespace." ); ok( !isWhitespaceTextNode(firstInput.inputEl.nextSibling), "Input element is not followed by whitespace." ); let labelContent = firstInput.labelEl.querySelector(".text"); ok( !isWhitespaceTextNode(labelContent.previousSibling), "Label text is not preceded by whitespace." ); // Usually labelContent won't be followed by anything, but adding this check // ensures the whitespace doesn't accidentally get re-added if (labelContent.nextSibling) { ok( !isWhitespaceTextNode(labelContent.nextSibling), "Label content is not followed by whitespace." ); } let containsWhitespace = false; for (let node of labelContent.childNodes) { if (isWhitespaceTextNode(node)) { containsWhitespace = true; break; } } ok( !containsWhitespace, "Label content doesn't contain any extra whitespace." ); } async testTextBasedInputEvents(selector) { let { trackEvent, verifyEvents } = this.getInputEventHelpers(); let target = await this.renderTemplate(); let input = target.querySelector(selector); input.addEventListener("click", trackEvent); input.addEventListener("change", trackEvent); input.addEventListener("input", trackEvent); const TEST_STRING = "mozilla!"; synthesizeMouseAtCenter(input.inputEl, {}); sendString(TEST_STRING); input.blur(); await TestUtils.waitForTick(); // Verify that we emit input events for each char of the string, // and that change events are fired when the input loses focus. verifyEvents([ { type: "click", localName: selector, value: "" }, ...Array.from(TEST_STRING).map((char, i) => ({ type: "input", localName: selector, value: TEST_STRING.substring(0, i + 1), })), { type: "change", localName: selector, value: TEST_STRING }, ]); } async verifyAriaLabel(selector) { const ARIA_LABEL = "I'm not visible"; let ariaLabelTemplate = this.templateFn({ value: "default", "aria-label": ARIA_LABEL, }); let renderTarget = await this.renderTemplate(ariaLabelTemplate); let input = renderTarget.querySelector(selector); let labelText = input.shadowRoot.querySelector(".text"); ok(!labelText, "No visible label text is rendered."); ok( !input.getAttribute("aria-label"), "aria-label is not set on the outer element." ); is( input.inputEl.getAttribute("aria-label"), ARIA_LABEL, "The aria-label is set on the input element." ); } /** * Verifies the behavior of nested elements for inputs that support nesting. * * @param {string} selector - HTML tag of the element under test. */ async verifyNestedFields(selector) { let { activatedProperty } = this; let nestedTemplate = this.templateFn({ label: "label", value: "foo", [activatedProperty]: true, }); let renderTarget = await this.renderTemplate(nestedTemplate); let parentInput = renderTarget.querySelector(selector); let nestedEls = []; let disabledEls = []; async function waitForUpdateComplete() { await parentInput.updateComplete; await Promise.all(nestedEls.map(el => el.updateComplete)); } // Create elements we expect to use in the nested slot. let nestedCheckbox = document.createElement("moz-checkbox"); nestedCheckbox.slot = "nested"; nestedCheckbox.label = "nested checkbox"; nestedCheckbox.checked = true; nestedCheckbox.value = "one"; nestedEls.push(nestedCheckbox); let nestedBoxButton = document.createElement("moz-box-button"); nestedBoxButton.slot = "nested"; nestedBoxButton.label = "nested box button"; nestedEls.push(nestedBoxButton); let nestedSelect = document.createElement("moz-select"); nestedSelect.slot = "nested"; nestedSelect.label = "nested select"; nestedSelect.value = "two"; nestedEls.push(nestedSelect); let options = ["one", "two", "three"].map(val => { let option = document.createElement("moz-option"); option.value = val; option.label = val; return option; }); nestedSelect.append(...options); parentInput.append(...nestedEls); let nestedDisabledCheckbox = document.createElement("moz-checkbox"); nestedDisabledCheckbox.slot = "nested"; nestedDisabledCheckbox.label = "nested disabled checkbox"; nestedDisabledCheckbox.disabled = true; parentInput.append(nestedDisabledCheckbox); disabledEls.push(nestedDisabledCheckbox); let nestedDisabledRadioGroup = document.createElement("moz-radio-group"); nestedDisabledRadioGroup.slot = "nested"; nestedDisabledRadioGroup.label = "nested disabled radio group"; nestedDisabledRadioGroup.disabled = true; parentInput.append(nestedDisabledRadioGroup); let nestedDisabledRadioOptions = ["one", "two", "three"].map(val => { let nestedDisabledRadioOption = document.createElement("moz-radio"); nestedDisabledRadioOption.label = `nested disabled radio ${val}`; nestedDisabledRadioOption.value = val; nestedDisabledRadioOption.disabled = val == "two"; nestedDisabledRadioGroup.append(nestedDisabledRadioOption); return nestedDisabledRadioOption; }); disabledEls.push(...nestedDisabledRadioOptions); await waitForUpdateComplete(); // Test the initial state when parent input is enabled and activated. nestedEls.forEach(nestedEl => { is( (nestedEl.inputEl ?? nestedEl.buttonEl).disabled, false, `The nested ${nestedEl.localName} element is enabled when the parent input is activated.` ); }); disabledEls.forEach(disabledEl => { is( disabledEl.inputEl.disabled, true, `The nested disabled ${disabledEl.localName} element is disabled when the parent input is activated.` ); }); // Deactivate the parent input. parentInput[activatedProperty] = false; await waitForUpdateComplete(); nestedEls.forEach(nestedEl => { is( (nestedEl.inputEl ?? nestedEl.buttonEl).disabled, true, `The nested ${nestedEl.localName} element is disabled when the parent input is deactivated.` ); }); disabledEls.forEach(disabledEl => { is( disabledEl.inputEl.disabled, true, `The nested disabled ${disabledEl.localName} element is disabled when the parent input is deactivated.` ); }); // Create another nested element to slot in later. let anotherNestedCheckbox = nestedCheckbox.cloneNode(true); anotherNestedCheckbox.label = "I get slotted later"; nestedEls.push(anotherNestedCheckbox); // Verify that additional slotted els get the correct disabled state. let checkboxSlotted = BrowserTestUtils.waitForEvent( parentInput.shadowRoot.querySelector(".nested"), "slotchange" ); parentInput.append(anotherNestedCheckbox); await checkboxSlotted; is( anotherNestedCheckbox.inputEl.disabled, true, "Newly slotted checkbox is initially disabled." ); // Reactivate the parent input. parentInput[activatedProperty] = true; await waitForUpdateComplete(); nestedEls.forEach(nestedEl => { is( (nestedEl.inputEl ?? nestedEl.buttonEl).disabled, false, `The nested ${nestedEl.localName} element is enabled when the parent input is reactivated.` ); }); disabledEls.forEach(disabledEl => { is( disabledEl.inputEl.disabled, true, `The nested disabled ${disabledEl.localName} element is disabled when the parent input is reactivated.` ); }); // Verify multiple layers of nested fields work as expected. let deeplyNestedCheckbox = nestedCheckbox.cloneNode(true); deeplyNestedCheckbox.label = "I'm deeply nested"; nestedEls.push(deeplyNestedCheckbox); let firstNestedCheckbox = nestedEls[0]; checkboxSlotted = BrowserTestUtils.waitForEvent( firstNestedCheckbox.shadowRoot.querySelector(".nested"), "slotchange" ); firstNestedCheckbox.append(deeplyNestedCheckbox); await checkboxSlotted; is( deeplyNestedCheckbox.inputEl.disabled, false, "Deeply nested checkbox is initially enabled." ); // Disabled the parent input. parentInput.disabled = true; await waitForUpdateComplete(); nestedEls.forEach(nestedEl => { is( (nestedEl.inputEl ?? nestedEl.buttonEl).disabled, true, `The nested ${nestedEl.localName} element is disabled when parent input is disabled.` ); }); disabledEls.forEach(disabledEl => { is( disabledEl.inputEl.disabled, true, `The nested disabled ${disabledEl.localName} element is disabled when the parent input is disabled.` ); }); // Reenable the parent input. parentInput.disabled = false; await waitForUpdateComplete(); nestedEls.forEach(nestedEl => { is( (nestedEl.inputEl ?? nestedEl.buttonEl).disabled, false, `The nested ${nestedEl.localName} element is enabled when parent input is reenabled.` ); }); disabledEls.forEach(disabledEl => { is( disabledEl.inputEl.disabled, true, `The nested disabled ${disabledEl.localName} element is disabled when the parent input is reenabled.` ); }); } /** * Verifies it is possible to set readonly attribute to the input element and remove it. * * @param {string} selector - HTML tag of the element under test. */ async verifyReadonly(selector) { const INITIAL_VALUE = "value"; const NEW_VALUE = "new value"; let renderTarget = await this.renderTemplate(); let firstInput = renderTarget.querySelector(selector); async function enterInputValue(inputComponent, inputValue) { synthesizeMouseAtCenter(inputComponent.inputEl, {}); sendString(inputValue); inputComponent.blur(); await TestUtils.waitForTick(); } ok( !firstInput.readonly, "Input has a readonly property set to false on initial render." ); is( firstInput.inputEl.value, "", "The initial value of the input element is an empty string." ); await enterInputValue(firstInput, INITIAL_VALUE); is( firstInput.inputEl.value, INITIAL_VALUE, "The value of the input element has changed." ); firstInput.readonly = true; await firstInput.updateComplete; ok(firstInput.readonly, "Input is readonly."); ok(firstInput.inputEl.readOnly, "Readonly state is propagated."); await enterInputValue(firstInput, NEW_VALUE); is( firstInput.inputEl.value, INITIAL_VALUE, "The value of the input element hasn't changed." ); firstInput.readonly = false; await firstInput.updateComplete; ok( !firstInput.readonly, "Input has a readonly property set to false again." ); ok(!firstInput.inputEl.readOnly, "Readonly state is propagated."); } }