/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; /** * Helper class for testing datetime input picker widget */ class DateTimeTestHelper { constructor() { this.panel = null; this.tab = null; this.frame = null; } /** * Opens a new tab with the URL of the test page, and make sure the picker is * ready for testing. * * @param {String} pageUrl * @param {bool} inFrame true if input is in the first child frame * @param {String} openMethod "click" or "showPicker" */ async openPicker(pageUrl, inFrame, openMethod = "click") { this.tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, pageUrl); let bc = gBrowser.selectedBrowser; if (inFrame) { await SpecialPowers.spawn(bc, [], async function () { const iframe = content.document.querySelector("iframe"); // Ensure the iframe's position is correct before doing any // other operations iframe.getBoundingClientRect(); }); bc = bc.browsingContext.children[0]; } await SpecialPowers.spawn(bc, [], async function () { // Ensure that screen coordinates are ok. await SpecialPowers.contentTransformsReceived(content); }); let shown = this.waitForPickerReady(); if (openMethod === "click") { await SpecialPowers.spawn(bc, [], () => { const input = content.document.querySelector("input"); const shadowRoot = SpecialPowers.wrap(input).openOrClosedShadowRoot; shadowRoot.getElementById("calendar-button").click(); }); } else if (openMethod === "showPicker") { await SpecialPowers.spawn(bc, [], function () { content.document.notifyUserGestureActivation(); content.document.querySelector("input").showPicker(); }); } this.panel = await shown; this.frame = this.panel.querySelector("#dateTimePopupFrame"); } promisePickerClosed() { return new Promise(resolve => { this.panel.addEventListener("popuphidden", resolve, { once: true }); }); } promiseChange(selector = "input") { return SpecialPowers.spawn( this.tab.linkedBrowser, [selector], async selector => { let input = content.document.querySelector(selector); await ContentTaskUtils.waitForEvent(input, "change", false, e => { ok( content.window.windowUtils.isHandlingUserInput, "isHandlingUserInput should be true" ); return true; }); } ); } waitForPickerReady() { return BrowserTestUtils.waitForDateTimePickerPanelShown(window); } /** * Find an element on the picker. * * @param {String} selector * @return {DOMElement} */ getElement(selector) { return this.frame.contentDocument.querySelector(selector); } /** * Find the children of an element on the picker. * * @param {String} selector * @return {Array} */ getChildren(selector) { return Array.from(this.getElement(selector).children); } /** * Click on an element * * @param {DOMElement} element */ click(element) { EventUtils.synthesizeMouseAtCenter(element, {}, this.frame.contentWindow); } async closePicker() { if (this.panel.state != "closed") { let pickerClosePromise = this.promisePickerClosed(); this.panel.hidePopup(); await pickerClosePromise; } } /** * Close the panel and the tab */ async tearDown() { await this.closePicker(); BrowserTestUtils.removeTab(this.tab); this.tab = null; } /** * Clean up after tests. Remove the frame to prevent leak. */ cleanup() { this.frame?.remove(); this.frame = null; this.panel = null; } } let helper = new DateTimeTestHelper(); registerCleanupFunction(() => { helper.cleanup(); }); const BTN_MONTH_YEAR = "#month-year-label", BTN_NEXT_MONTH = ".next", BTN_PREV_MONTH = ".prev", BTN_CLEAR = "#clear-button", DAY_SELECTED = ".selection", DAY_TODAY = ".today", DAYS_VIEW = ".days-view", DIALOG_PICKER = "#date-picker", MONTH_YEAR = ".month-year", MONTH_YEAR_NAV = ".month-year-nav", MONTH_YEAR_VIEW = ".month-year-view", SPINNER_MONTH = "#spinner-month", SPINNER_YEAR = "#spinner-year", WEEK_HEADER = ".week-header"; const DATE_FORMAT = new Intl.DateTimeFormat("en-US", { year: "numeric", month: "long", timeZone: "UTC", }).format; const DATE_FORMAT_LOCAL = new Intl.DateTimeFormat("en-US", { year: "numeric", month: "long", }).format; /** * Helper function to find and return a gridcell element * for a specific day of the month * * @return {Array[String]} TextContent of each gridcell within a calendar grid */ function getCalendarText() { let calendarCells = []; for (const tr of helper.getChildren(DAYS_VIEW)) { for (const td of tr.children) { calendarCells.push(td.textContent); } } return calendarCells; } function getCalendarClassList() { let calendarCellsClasses = []; for (const tr of helper.getChildren(DAYS_VIEW)) { for (const td of tr.children) { calendarCellsClasses.push(td.classList); } } return calendarCellsClasses; } /** * Helper function to find and return a gridcell element * for a specific day of the month * * @param {Number} day: A day of the month to find in the month grid * * @return {HTMLElement} A gridcell that represents the needed day of the month */ function getDayEl(dayNum) { const dayEls = Array.from( helper.getElement(DAYS_VIEW).querySelectorAll("td") ); return dayEls.find(el => el.textContent === dayNum.toString()); } function mergeArrays(a, b) { return a.map((classlist, index) => classlist.concat(b[index])); } /** * Helper function to check if a DOM element has a specific attribute * * @param {DOMElement} el: DOM Element to be tested * @param {String} attr: The name of the attribute to be tested */ function testAttribute(el, attr) { Assert.ok( el.hasAttribute(attr), `The "${el}" element has a "${attr}" attribute` ); } /** * Helper function to check for l10n of an element's attribute * * @param {DOMElement} el: DOM Element to be tested * @param {String} attr: The name of the attribute to be tested * @param {String} id: Value of the "data-l10n-id" attribute of the element * @param {Object} args: Args provided by the l10n object of the element */ function testAttributeL10n(el, attr, id, args = null) { testAttribute(el, attr); testLocalization(el, id, args); } /** * Helper function to check the value of a Calendar button's specific attribute * * @param {String} attr: The name of the attribute to be tested * @param {String} val: Value that is expected to be assigned to the attribute. * @param {Boolean} presenceOnly: If "true", test only the presence of the attribute */ async function testCalendarBtnAttribute(attr, val, presenceOnly = false) { let browser = helper.tab.linkedBrowser; await SpecialPowers.spawn( browser, [attr, val, presenceOnly], (attr, val, presenceOnly) => { const input = content.document.querySelector("input"); const shadowRoot = SpecialPowers.wrap(input).openOrClosedShadowRoot; const calendarBtn = shadowRoot.getElementById("calendar-button"); if (presenceOnly) { Assert.ok( calendarBtn.hasAttribute(attr), `Calendar button has ${attr} attribute` ); } else { Assert.equal( calendarBtn.getAttribute(attr), val, `Calendar button has ${attr} attribute set to ${val}` ); } } ); } /** * Helper function to test if a submission/dismissal keyboard shortcut works * on a month or a year selection spinner * * @param {String} key: A keyboard Event.key that will be synthesized * @param {Object} document: Reference to the content document * of the #dateTimePopupFrame * @param {Number} tabs: How many times "Tab" key should be pressed * to move a keyboard focus to a needed spinner * (1 for month/default and 2 for year) * * @description Starts with the month-year toggle button being focused * on the date/datetime-local input's datepicker panel */ async function testKeyOnSpinners(key, document, tabs = 1) { info(`Testing "${key}" key behavior`); Assert.equal( document.activeElement, helper.getElement(BTN_MONTH_YEAR), "The month-year toggle button is focused" ); // Open the month-year selection panel with spinners: await EventUtils.synthesizeKey(" ", {}); Assert.equal( helper.getElement(BTN_MONTH_YEAR).getAttribute("aria-expanded"), "true", "Month-year button is expanded when the spinners are shown" ); Assert.ok( BrowserTestUtils.isVisible(helper.getElement(MONTH_YEAR_VIEW)), "Month-year selection panel is visible" ); // Move focus from the month-year toggle button to one of spinners: await EventUtils.synthesizeKey("KEY_Tab", { repeat: tabs }); Assert.equal( document.activeElement.getAttribute("role"), "spinbutton", "The spinner is focused" ); // Confirm the spinbutton choice and close the month-year selection panel: await EventUtils.synthesizeKey(key, {}); Assert.equal( helper.getElement(BTN_MONTH_YEAR).getAttribute("aria-expanded"), "false", "Month-year button is collapsed when the spinners are hidden" ); Assert.ok( BrowserTestUtils.isHidden(helper.getElement(MONTH_YEAR_VIEW)), "Month-year selection panel is not visible" ); Assert.equal( document.activeElement, helper.getElement(DAYS_VIEW).querySelector('[tabindex="0"]'), "A focusable day within a calendar grid is focused" ); // Return the focus to the month-year toggle button for future tests // (passing a Previous button along the way): await EventUtils.synthesizeKey("KEY_Tab", { repeat: 3 }); } /** * Helper function to check for localization attributes of a DOM element * * @param {DOMElement} el: DOM Element to be tested * @param {String} id: Value of the "data-l10n-id" attribute of the element * @param {Object} args: Args provided by the l10n object of the element */ function testLocalization(el, id, args = null) { const l10nAttrs = document.l10n.getAttributes(el); Assert.deepEqual( l10nAttrs, { id, args, }, `The "${id}" element is localizable` ); } /** * Helper function to check if a CSS property respects reduced motion mode * * @param {DOMElement} el: DOM Element to be tested * @param {String} prop: The name of the CSS property to be tested * @param {Object} valueNotReduced: Default value of the tested CSS property * for "prefers-reduced-motion: no-preference" * @param {String} valueReduced: Value of the tested CSS property * for "prefers-reduced-motion: reduce" */ async function testReducedMotionProp(el, prop, valueNotReduced, valueReduced) { info(`Test the panel's CSS ${prop} value depending on a reduced motion mode`); // Set "prefers-reduced-motion" media to "no-preference" await SpecialPowers.pushPrefEnv({ set: [["ui.prefersReducedMotion", 0]], }); ok( matchMedia("(prefers-reduced-motion: no-preference)").matches, "The reduce motion mode is not active" ); is( getComputedStyle(el).getPropertyValue(prop), valueNotReduced, `Default ${prop} will be provided, when a reduce motion mode is not active` ); // Set "prefers-reduced-motion" media to "reduce" await SpecialPowers.pushPrefEnv({ set: [["ui.prefersReducedMotion", 1]], }); ok( matchMedia("(prefers-reduced-motion: reduce)").matches, "The reduce motion mode is active" ); is( getComputedStyle(el).getPropertyValue(prop), valueReduced, `Reduced ${prop} will be provided, when a reduce motion mode is active` ); } async function verifyPickerPosition(browsingContext, inputId) { let inputRect = await SpecialPowers.spawn( browsingContext, [inputId], async function (inputIdChild) { let rect = content.document .getElementById(inputIdChild) .getBoundingClientRect(); return { left: content.mozInnerScreenX + rect.left, bottom: content.mozInnerScreenY + rect.bottom, }; } ); function is_close(got, exp, msg) { // on some platforms we see differences of a fraction of a pixel - so // allow any difference of < 1 pixels as being OK. Assert.ok( Math.abs(got - exp) < 1, msg + ": " + got + " should be equal(-ish) to " + exp ); } const marginLeft = parseFloat(getComputedStyle(helper.panel).marginLeft); const marginTop = parseFloat(getComputedStyle(helper.panel).marginTop); is_close( helper.panel.screenX - marginLeft, inputRect.left, "datepicker x position" ); is_close( helper.panel.screenY - marginTop, inputRect.bottom, "datepicker y position" ); }