1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
3 * You can obtain one at http://mozilla.org/MPL/2.0/. */
5 /* eslint-disable no-restricted-globals */
9 ChromeUtils.defineESModuleGetters(lazy, {
10 setTimeout: "resource://gre/modules/Timer.sys.mjs",
13 "chrome://remote/content/shared/webdriver/Accessibility.sys.mjs",
14 atom: "chrome://remote/content/marionette/atom.sys.mjs",
15 dom: "chrome://remote/content/shared/DOM.sys.mjs",
16 error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
17 event: "chrome://remote/content/shared/webdriver/Event.sys.mjs",
18 Log: "chrome://remote/content/shared/Log.sys.mjs",
19 pprint: "chrome://remote/content/shared/Format.sys.mjs",
20 TimedPromise: "chrome://remote/content/marionette/sync.sys.mjs",
23 ChromeUtils.defineLazyGetter(lazy, "logger", () =>
24 lazy.Log.get(lazy.Log.TYPES.MARIONETTE)
27 // dragService may be null if it's in the headless mode (e.g., on Linux).
28 // It depends on the platform, though.
29 ChromeUtils.defineLazyGetter(lazy, "dragService", () => {
31 return Cc["@mozilla.org/widget/dragservice;1"].getService(
35 // If we're in the headless mode, the drag service may be never
36 // instantiated. In this case, an exception is thrown. Let's ignore
37 // any exceptions since without the drag service, nobody can create a
43 /** XUL elements that support disabled attribute. */
44 const DISABLED_ATTRIBUTE_SUPPORTED_XUL = new Set([
68 * Common form controls that user can change the value property
71 const COMMON_FORM_CONTROLS = new Set(["input", "textarea", "select"]);
74 * Input elements that do not fire <tt>input</tt> and <tt>change</tt>
75 * events when value property changes.
77 const INPUT_TYPES_NO_EVENT = new Set([
89 export const interaction = {};
92 * Interact with an element by clicking it.
94 * The element is scrolled into view before visibility- or interactability
95 * checks are performed.
97 * Selenium-style visibility checks will be performed
98 * if <var>specCompat</var> is false (default). Otherwise
99 * pointer-interactability checks will be performed. If either of these
100 * fail an {@link ElementNotInteractableError} is thrown.
102 * If <var>strict</var> is enabled (defaults to disabled), further
103 * accessibility checks will be performed, and these may result in an
104 * {@link ElementNotAccessibleError} being returned.
106 * When <var>el</var> is not enabled, an {@link InvalidElementStateError}
109 * @param {(DOMElement|XULElement)} el
111 * @param {boolean=} [strict=false] strict
112 * Enforce strict accessibility tests.
113 * @param {boolean=} [specCompat=false] specCompat
114 * Use WebDriver specification compatible interactability definition.
116 * @throws {ElementNotInteractableError}
117 * If either Selenium-style visibility check or
118 * pointer-interactability check fails.
119 * @throws {ElementClickInterceptedError}
120 * If <var>el</var> is obscured by another element and a click would
121 * not hit, in <var>specCompat</var> mode.
122 * @throws {ElementNotAccessibleError}
123 * If <var>strict</var> is true and element is not accessible.
124 * @throws {InvalidElementStateError}
125 * If <var>el</var> is not enabled.
127 interaction.clickElement = async function (
132 const a11y = lazy.accessibility.get(strict);
133 if (lazy.dom.isXULElement(el)) {
134 await chromeClick(el, a11y);
135 } else if (specCompat) {
136 await webdriverClickElement(el, a11y);
138 lazy.logger.trace(`Using non spec-compatible element click`);
139 await seleniumClickElement(el, a11y);
143 async function webdriverClickElement(el, a11y) {
144 const win = getWindow(el);
147 if (el.localName == "input" && el.type == "file") {
148 throw new lazy.error.InvalidArgumentError(
149 "Cannot click <input type=file> elements"
153 let containerEl = lazy.dom.getContainer(el);
156 if (!lazy.dom.isInView(containerEl)) {
157 lazy.dom.scrollIntoView(containerEl);
161 // TODO(ato): wait for containerEl to be in view
164 // if we cannot bring the container element into the viewport
165 // there is no point in checking if it is pointer-interactable
166 if (!lazy.dom.isInView(containerEl)) {
167 throw new lazy.error.ElementNotInteractableError(
168 lazy.pprint`Element ${el} could not be scrolled into view`
173 let rects = containerEl.getClientRects();
174 let clickPoint = lazy.dom.getInViewCentrePoint(rects[0], win);
176 if (lazy.dom.isObscured(containerEl)) {
177 throw new lazy.error.ElementClickInterceptedError(
185 let acc = await a11y.assertAccessible(el, true);
186 a11y.assertVisible(acc, el, true);
187 a11y.assertEnabled(acc, el, true);
188 a11y.assertActionable(acc, el);
191 if (el.localName == "option") {
192 interaction.selectOption(el);
194 // Synthesize a pointerMove action.
195 lazy.event.synthesizeMouseAtPoint(
200 allowToHandleDragDrop: true,
205 if (lazy.dragService?.getCurrentSession(win)) {
206 // Special handling is required if the mousemove started a drag session.
207 // In this case, mousedown event shouldn't be fired, and the mouseup should
208 // end the session. Therefore, we should synthesize only mouseup.
209 lazy.event.synthesizeMouseAtPoint(
214 allowToHandleDragDrop: true,
220 let clicked = interaction.flushEventLoop(containerEl);
222 // Synthesize a pointerDown + pointerUp action.
223 lazy.event.synthesizeMouseAtPoint(
226 { allowToHandleDragDrop: true },
235 // if the click causes navigation, the post-navigation checks are
236 // handled by navigate.js
239 async function chromeClick(el, a11y) {
240 if (!(await lazy.dom.isEnabled(el))) {
241 throw new lazy.error.InvalidElementStateError("Element is not enabled");
244 let acc = await a11y.assertAccessible(el, true);
245 a11y.assertVisible(acc, el, true);
246 a11y.assertEnabled(acc, el, true);
247 a11y.assertActionable(acc, el);
249 if (el.localName == "option") {
250 interaction.selectOption(el);
256 async function seleniumClickElement(el, a11y) {
257 let win = getWindow(el);
259 let visibilityCheckEl = el;
260 if (el.localName == "option") {
261 visibilityCheckEl = lazy.dom.getContainer(el);
264 if (!(await lazy.dom.isVisible(visibilityCheckEl))) {
265 throw new lazy.error.ElementNotInteractableError();
268 if (!(await lazy.dom.isEnabled(el))) {
269 throw new lazy.error.InvalidElementStateError("Element is not enabled");
272 let acc = await a11y.assertAccessible(el, true);
273 a11y.assertVisible(acc, el, true);
274 a11y.assertEnabled(acc, el, true);
275 a11y.assertActionable(acc, el);
277 if (el.localName == "option") {
278 interaction.selectOption(el);
280 let rects = el.getClientRects();
281 let centre = lazy.dom.getInViewCentrePoint(rects[0], win);
283 lazy.event.synthesizeMouseAtPoint(centre.x, centre.y, opts, win);
288 * Select <tt><option></tt> element in a <tt><select></tt>
291 * Because the dropdown list of select elements are implemented using
292 * native widget technology, our trusted synthesised events are not able
293 * to reach them. Dropdowns are instead handled mimicking DOM events,
294 * which for obvious reasons is not ideal, but at the current point in
295 * time considered to be good enough.
297 * @param {HTMLOptionElement} el
298 * Option element to select.
300 * @throws {TypeError}
301 * If <var>el</var> is a XUL element or not an <tt><option></tt>
304 * If unable to find <var>el</var>'s parent <tt><select></tt>
307 interaction.selectOption = function (el) {
308 if (lazy.dom.isXULElement(el)) {
309 throw new TypeError("XUL dropdowns not supported");
311 if (el.localName != "option") {
312 throw new TypeError(lazy.pprint`Expected <option> element, got ${el}`);
315 let containerEl = lazy.dom.getContainer(el);
317 lazy.event.mouseover(containerEl);
318 lazy.event.mousemove(containerEl);
319 lazy.event.mousedown(containerEl);
323 // Clicking <option> in <select> should not be deselected if selected.
324 // However, clicking one in a <select multiple> should toggle
325 // selectedness the way holding down Control works.
326 if (containerEl.multiple) {
327 el.selected = !el.selected;
328 } else if (!el.selected) {
331 lazy.event.input(containerEl);
332 lazy.event.change(containerEl);
335 lazy.event.mouseup(containerEl);
336 lazy.event.click(containerEl);
341 * Clears the form control or the editable element, if required.
343 * Before clearing the element, it will attempt to scroll it into
344 * view if it is not already in the viewport. An error is raised
345 * if the element cannot be brought into view.
347 * If the element is a submittable form control and it is empty
348 * (it has no value or it has no files associated with it, in the
349 * case it is a <code><input type=file></code> element) or
350 * it is an editing host and its <code>innerHTML</code> content IDL
351 * attribute is empty, this function acts as a no-op.
353 * @param {Element} el
356 * @throws {InvalidElementStateError}
357 * If element is disabled, read-only, non-editable, not a submittable
358 * element or not an editing host, or cannot be scrolled into view.
360 interaction.clearElement = function (el) {
361 if (lazy.dom.isDisabled(el)) {
362 throw new lazy.error.InvalidElementStateError(
363 lazy.pprint`Element is disabled: ${el}`
366 if (lazy.dom.isReadOnly(el)) {
367 throw new lazy.error.InvalidElementStateError(
368 lazy.pprint`Element is read-only: ${el}`
371 if (!lazy.dom.isEditable(el)) {
372 throw new lazy.error.InvalidElementStateError(
373 lazy.pprint`Unable to clear element that cannot be edited: ${el}`
377 if (!lazy.dom.isInView(el)) {
378 lazy.dom.scrollIntoView(el);
380 if (!lazy.dom.isInView(el)) {
381 throw new lazy.error.ElementNotInteractableError(
382 lazy.pprint`Element ${el} could not be scrolled into view`
386 if (lazy.dom.isEditingHost(el)) {
387 clearContentEditableElement(el);
389 clearResettableElement(el);
393 function clearContentEditableElement(el) {
394 if (el.innerHTML === "") {
402 function clearResettableElement(el) {
403 if (!lazy.dom.isMutableFormControl(el)) {
404 throw new lazy.error.InvalidElementStateError(
405 lazy.pprint`Not an editable form control: ${el}`
412 isEmpty = !el.files.length;
416 isEmpty = el.value === "";
420 if (el.validity.valid && isEmpty) {
426 lazy.event.change(el);
431 * Waits until the event loop has spun enough times to process the
432 * DOM events generated by clicking an element, or until the document
435 * @param {Element} el
436 * Element that is expected to receive the click.
439 * Promise is resolved once <var>el</var> has been clicked
440 * (its <code>click</code> event fires), the document is unloaded,
441 * or a 500 ms timeout is reached.
443 interaction.flushEventLoop = async function (el) {
444 const win = el.ownerGlobal;
445 let unloadEv, clickEv;
447 let spinEventLoop = resolve => {
450 lazy.logger.trace(`Received DOM event click for ${event.target}`);
454 lazy.setTimeout(resolve, 0);
458 win.addEventListener("unload", unloadEv, { mozSystemGroup: true });
459 el.addEventListener("click", clickEv, { mozSystemGroup: true });
461 let removeListeners = () => {
462 // only one event fires
463 win.removeEventListener("unload", unloadEv);
464 el.removeEventListener("click", clickEv);
467 return new lazy.TimedPromise(spinEventLoop, {
470 }).then(removeListeners);
474 * If <var>el<var> is a textual form control, or is contenteditable,
475 * and no previous selection state exists, move the caret to the end
476 * of the form control.
478 * The element has to be a <code><input type=text></code> or
479 * <code><textarea></code> element, or have the contenteditable
480 * attribute set, for the cursor to be moved.
482 * @param {Element} el
483 * Element to potential move the caret in.
485 interaction.moveCaretToEnd = function (el) {
486 if (!lazy.dom.isDOMElement(el)) {
490 let isTextarea = el.localName == "textarea";
491 let isInputText = el.localName == "input" && el.type == "text";
493 if (isTextarea || isInputText) {
494 if (el.selectionEnd == 0) {
495 let len = el.value.length;
496 el.setSelectionRange(len, len);
498 } else if (el.isContentEditable) {
499 let selection = getWindow(el).getSelection();
500 selection.setPosition(el, el.childNodes.length);
505 * Performs checks if <var>el</var> is keyboard-interactable.
507 * To decide if an element is keyboard-interactable various properties,
508 * and computed CSS styles have to be evaluated. Whereby it has to be taken
509 * into account that the element can be part of a container (eg. option),
510 * and as such the container has to be checked instead.
512 * @param {Element} el
516 * True if element is keyboard-interactable, false otherwise.
518 interaction.isKeyboardInteractable = function (el) {
519 const win = getWindow(el);
521 // body and document element are always keyboard-interactable
522 if (el.localName === "body" || el === win.document.documentElement) {
526 // context menu popups do not take the focus from the document.
527 const menuPopup = el.closest("menupopup");
529 if (menuPopup.state !== "open") {
530 // closed menupopups are not keyboard interactable.
534 const menuItem = el.closest("menuitem");
536 // hidden or disabled menu items are not keyboard interactable.
537 return !menuItem.disabled && !menuItem.hidden;
543 return Services.focus.elementIsFocusable(el, 0);
547 * Updates an `<input type=file>`'s file list with given `paths`.
549 * Hereby will the file list be appended with `paths` if the
550 * element allows multiple files. Otherwise the list will be
553 * @param {HTMLInputElement} el
554 * An `input type=file` element.
555 * @param {Array.<string>} paths
556 * List of full paths to any of the files to be uploaded.
558 * @throws {InvalidArgumentError}
559 * If `path` doesn't exist.
561 interaction.uploadFiles = async function (el, paths) {
564 if (el.hasAttribute("multiple")) {
565 // for multiple file uploads new files will be appended
566 files = Array.prototype.slice.call(el.files);
567 } else if (paths.length > 1) {
568 throw new lazy.error.InvalidArgumentError(
569 lazy.pprint`Element ${el} doesn't accept multiple files`
573 for (let path of paths) {
577 file = await File.createFromFileName(path);
579 throw new lazy.error.InvalidArgumentError("File not found: " + path);
585 el.mozSetFileArray(files);
589 * Sets a form element's value.
591 * @param {DOMElement} el
592 * An form element, e.g. input, textarea, etc.
593 * @param {string} value
594 * The value to be set.
596 * @throws {TypeError}
597 * If <var>el</var> is not an supported form element.
599 interaction.setFormControlValue = function (el, value) {
600 if (!COMMON_FORM_CONTROLS.has(el.localName)) {
601 throw new TypeError("This function is for form elements only");
606 if (INPUT_TYPES_NO_EVENT.has(el.type)) {
610 lazy.event.input(el);
611 lazy.event.change(el);
615 * Send keys to element.
617 * @param {DOMElement|XULElement} el
618 * Element to send key events to.
619 * @param {Array.<string>} value
620 * Sequence of keystrokes to send to the element.
621 * @param {object=} options
622 * @param {boolean=} options.strictFileInteractability
623 * Run interactability checks on `<input type=file>` elements.
624 * @param {boolean=} options.accessibilityChecks
625 * Enforce strict accessibility tests.
626 * @param {boolean=} options.webdriverClick
627 * Use WebDriver specification compatible interactability definition.
629 interaction.sendKeysToElement = async function (
633 strictFileInteractability = false,
634 accessibilityChecks = false,
635 webdriverClick = false,
638 const a11y = lazy.accessibility.get(accessibilityChecks);
640 if (webdriverClick) {
641 await webdriverSendKeysToElement(
645 strictFileInteractability
648 await legacySendKeysToElement(el, value, a11y);
652 async function webdriverSendKeysToElement(
656 strictFileInteractability
658 const win = getWindow(el);
660 if (el.type !== "file" || strictFileInteractability) {
661 let containerEl = lazy.dom.getContainer(el);
663 if (!lazy.dom.isInView(containerEl)) {
664 lazy.dom.scrollIntoView(containerEl);
667 // TODO: Wait for element to be keyboard-interactible
668 if (!interaction.isKeyboardInteractable(containerEl)) {
669 throw new lazy.error.ElementNotInteractableError(
670 lazy.pprint`Element ${el} is not reachable by keyboard`
674 if (win.document.activeElement !== containerEl) {
676 // This validates the correct element types internally
677 interaction.moveCaretToEnd(containerEl);
681 let acc = await a11y.assertAccessible(el, true);
682 a11y.assertActionable(acc, el);
684 if (el.type == "file") {
685 let paths = value.split("\n");
686 await interaction.uploadFiles(el, paths);
688 lazy.event.input(el);
689 lazy.event.change(el);
690 } else if (el.type == "date" || el.type == "time") {
691 interaction.setFormControlValue(el, value);
693 lazy.event.sendKeys(value, win);
697 async function legacySendKeysToElement(el, value, a11y) {
698 const win = getWindow(el);
700 if (el.type == "file") {
702 await interaction.uploadFiles(el, [value]);
704 lazy.event.input(el);
705 lazy.event.change(el);
706 } else if (el.type == "date" || el.type == "time") {
707 interaction.setFormControlValue(el, value);
709 let visibilityCheckEl = el;
710 if (el.localName == "option") {
711 visibilityCheckEl = lazy.dom.getContainer(el);
714 if (!(await lazy.dom.isVisible(visibilityCheckEl))) {
715 throw new lazy.error.ElementNotInteractableError(
716 "Element is not visible"
720 let acc = await a11y.assertAccessible(el, true);
721 a11y.assertActionable(acc, el);
723 interaction.moveCaretToEnd(el);
725 lazy.event.sendKeys(value, win);
730 * Determine the element displayedness of an element.
732 * @param {DOMElement|XULElement} el
733 * Element to determine displayedness of.
734 * @param {boolean=} [strict=false] strict
735 * Enforce strict accessibility tests.
738 * True if element is displayed, false otherwise.
740 interaction.isElementDisplayed = async function (el, strict = false) {
741 let win = getWindow(el);
742 let displayed = await lazy.atom.isElementDisplayed(el, win);
744 let a11y = lazy.accessibility.get(strict);
745 return a11y.assertAccessible(el).then(acc => {
746 a11y.assertVisible(acc, el, displayed);
752 * Check if element is enabled.
754 * @param {DOMElement|XULElement} el
755 * Element to test if is enabled.
758 * True if enabled, false otherwise.
760 interaction.isElementEnabled = async function (el, strict = false) {
762 let win = getWindow(el);
764 if (lazy.dom.isXULElement(el)) {
765 // check if XUL element supports disabled attribute
766 if (DISABLED_ATTRIBUTE_SUPPORTED_XUL.has(el.tagName.toUpperCase())) {
768 el.hasAttribute("disabled") &&
769 el.getAttribute("disabled") === "true"
775 ["application/xml", "text/xml"].includes(win.document.contentType)
779 enabled = await lazy.dom.isEnabled(el);
782 let a11y = lazy.accessibility.get(strict);
783 return a11y.assertAccessible(el).then(acc => {
784 a11y.assertEnabled(acc, el, enabled);
790 * Determines if the referenced element is selected or not, with
791 * an additional accessibility check if <var>strict</var> is true.
793 * This operation only makes sense on input elements of the checkbox-
794 * and radio button states, and option elements.
796 * @param {(DOMElement|XULElement)} el
797 * Element to test if is selected.
798 * @param {boolean=} [strict=false] strict
799 * Enforce strict accessibility tests.
802 * True if element is selected, false otherwise.
804 * @throws {ElementNotAccessibleError}
805 * If <var>el</var> is not accessible when <var>strict</var> is true.
807 interaction.isElementSelected = function (el, strict = false) {
808 let selected = lazy.dom.isSelected(el);
810 let a11y = lazy.accessibility.get(strict);
811 return a11y.assertAccessible(el).then(acc => {
812 a11y.assertSelected(acc, el, selected);
817 function getWindow(el) {
818 // eslint-disable-next-line mozilla/use-ownerGlobal
819 return el.ownerDocument.defaultView;