Source code
Revision control
Copy as Markdown
Other Tools
/* Any copyright is dedicated to the Public Domain.
"use strict";
const { TestUtils } = ChromeUtils.importESModule(
);
const { BrowserTestUtils } = ChromeUtils.importESModule(
);
class InputTestHelpers {
/**
* 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,
staticHtml: this.staticHtml,
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.spread = lit.directive(this.SpreadDirective);
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) => <my-input ${attrs}>${children}</my-input>`
*/
async setupInputTests({ 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 renderInputElements(template = this.templateFn()) {
this.render(this.html``, this.renderTarget);
this.render(template, this.renderTarget);
await this.renderTarget.firstElementChild.updateComplete;
return this.renderTarget;
}
/**
* 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.target.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);
}
}
/**
* 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.renderInputElements(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.renderInputElements();
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.renderInputElements(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.renderInputElements(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.renderInputElements();
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`<span slot="description">${SLOTTED_DESCRIPTION}</span>`],
[
{ description: ATTR_DESCRIPTION },
this.html`<span slot="description">${SLOTTED_DESCRIPTION}</span>`,
],
];
let renderTarget = await this.renderInputElements(
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`<a slot="support-link" href="www.example.com">Help me!</a>`,
],
];
let renderTarget = await this.renderInputElements(
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().parentElement.id,
"description",
"Support link is rendered in the description if a description is present."
);
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.parentElement.id,
"description",
"Support link is rendered in 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.renderInputElements(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.renderInputElements();
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.renderInputElements(whitespaceTemplate);
let firstInput = renderTarget.querySelector(selector);
if (firstInput.constructor.inputLayout == "block") {
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.renderInputElements();
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 },
]);
}
}