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",
12 accessibility: "chrome://remote/content/marionette/accessibility.sys.mjs",
13 atom: "chrome://remote/content/marionette/atom.sys.mjs",
14 dom: "chrome://remote/content/shared/DOM.sys.mjs",
15 error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
16 event: "chrome://remote/content/shared/webdriver/Event.sys.mjs",
17 Log: "chrome://remote/content/shared/Log.sys.mjs",
18 pprint: "chrome://remote/content/shared/Format.sys.mjs",
19 TimedPromise: "chrome://remote/content/marionette/sync.sys.mjs",
22 ChromeUtils.defineLazyGetter(lazy, "logger", () =>
23 lazy.Log.get(lazy.Log.TYPES.MARIONETTE)
26 // dragService may be null if it's in the headless mode (e.g., on Linux).
27 // It depends on the platform, though.
28 ChromeUtils.defineLazyGetter(lazy, "dragService", () => {
30 return Cc["@mozilla.org/widget/dragservice;1"].getService(
34 // If we're in the headless mode, the drag service may be never
35 // instantiated. In this case, an exception is thrown. Let's ignore
36 // any exceptions since without the drag service, nobody can create a
42 /** XUL elements that support disabled attribute. */
43 const DISABLED_ATTRIBUTE_SUPPORTED_XUL = new Set([
67 * Common form controls that user can change the value property
70 const COMMON_FORM_CONTROLS = new Set(["input", "textarea", "select"]);
73 * Input elements that do not fire <tt>input</tt> and <tt>change</tt>
74 * events when value property changes.
76 const INPUT_TYPES_NO_EVENT = new Set([
88 export const interaction = {};
91 * Interact with an element by clicking it.
93 * The element is scrolled into view before visibility- or interactability
94 * checks are performed.
96 * Selenium-style visibility checks will be performed
97 * if <var>specCompat</var> is false (default). Otherwise
98 * pointer-interactability checks will be performed. If either of these
99 * fail an {@link ElementNotInteractableError} is thrown.
101 * If <var>strict</var> is enabled (defaults to disabled), further
102 * accessibility checks will be performed, and these may result in an
103 * {@link ElementNotAccessibleError} being returned.
105 * When <var>el</var> is not enabled, an {@link InvalidElementStateError}
108 * @param {(DOMElement|XULElement)} el
110 * @param {boolean=} [strict=false] strict
111 * Enforce strict accessibility tests.
112 * @param {boolean=} [specCompat=false] specCompat
113 * Use WebDriver specification compatible interactability definition.
115 * @throws {ElementNotInteractableError}
116 * If either Selenium-style visibility check or
117 * pointer-interactability check fails.
118 * @throws {ElementClickInterceptedError}
119 * If <var>el</var> is obscured by another element and a click would
120 * not hit, in <var>specCompat</var> mode.
121 * @throws {ElementNotAccessibleError}
122 * If <var>strict</var> is true and element is not accessible.
123 * @throws {InvalidElementStateError}
124 * If <var>el</var> is not enabled.
126 interaction.clickElement = async function (
131 const a11y = lazy.accessibility.get(strict);
132 if (lazy.dom.isXULElement(el)) {
133 await chromeClick(el, a11y);
134 } else if (specCompat) {
135 await webdriverClickElement(el, a11y);
137 lazy.logger.trace(`Using non spec-compatible element click`);
138 await seleniumClickElement(el, a11y);
142 async function webdriverClickElement(el, a11y) {
143 const win = getWindow(el);
146 if (el.localName == "input" && el.type == "file") {
147 throw new lazy.error.InvalidArgumentError(
148 "Cannot click <input type=file> elements"
152 let containerEl = lazy.dom.getContainer(el);
155 if (!lazy.dom.isInView(containerEl)) {
156 lazy.dom.scrollIntoView(containerEl);
160 // TODO(ato): wait for containerEl to be in view
163 // if we cannot bring the container element into the viewport
164 // there is no point in checking if it is pointer-interactable
165 if (!lazy.dom.isInView(containerEl)) {
166 throw new lazy.error.ElementNotInteractableError(
167 lazy.pprint`Element ${el} could not be scrolled into view`
172 let rects = containerEl.getClientRects();
173 let clickPoint = lazy.dom.getInViewCentrePoint(rects[0], win);
175 if (lazy.dom.isObscured(containerEl)) {
176 throw new lazy.error.ElementClickInterceptedError(
184 let acc = await a11y.assertAccessible(el, true);
185 a11y.assertVisible(acc, el, true);
186 a11y.assertEnabled(acc, el, true);
187 a11y.assertActionable(acc, el);
190 if (el.localName == "option") {
191 interaction.selectOption(el);
193 // Synthesize a pointerMove action.
194 lazy.event.synthesizeMouseAtPoint(
199 allowToHandleDragDrop: true,
204 if (lazy.dragService?.getCurrentSession()) {
205 // Special handling is required if the mousemove started a drag session.
206 // In this case, mousedown event shouldn't be fired, and the mouseup should
207 // end the session. Therefore, we should synthesize only mouseup.
208 lazy.event.synthesizeMouseAtPoint(
213 allowToHandleDragDrop: true,
219 let clicked = interaction.flushEventLoop(containerEl);
221 // Synthesize a pointerDown + pointerUp action.
222 lazy.event.synthesizeMouseAtPoint(
225 { allowToHandleDragDrop: true },
234 // if the click causes navigation, the post-navigation checks are
235 // handled by navigate.js
238 async function chromeClick(el, a11y) {
239 const win = getWindow(el);
241 if (!(await lazy.atom.isElementEnabled(el, win))) {
242 throw new lazy.error.InvalidElementStateError("Element is not enabled");
245 let acc = await a11y.assertAccessible(el, true);
246 a11y.assertVisible(acc, el, true);
247 a11y.assertEnabled(acc, el, true);
248 a11y.assertActionable(acc, el);
250 if (el.localName == "option") {
251 interaction.selectOption(el);
257 async function seleniumClickElement(el, a11y) {
258 let win = getWindow(el);
260 let visibilityCheckEl = el;
261 if (el.localName == "option") {
262 visibilityCheckEl = lazy.dom.getContainer(el);
265 if (!(await lazy.dom.isVisible(visibilityCheckEl))) {
266 throw new lazy.error.ElementNotInteractableError();
269 if (!(await lazy.atom.isElementEnabled(el, win))) {
270 throw new lazy.error.InvalidElementStateError("Element is not enabled");
273 let acc = await a11y.assertAccessible(el, true);
274 a11y.assertVisible(acc, el, true);
275 a11y.assertEnabled(acc, el, true);
276 a11y.assertActionable(acc, el);
278 if (el.localName == "option") {
279 interaction.selectOption(el);
281 let rects = el.getClientRects();
282 let centre = lazy.dom.getInViewCentrePoint(rects[0], win);
284 lazy.event.synthesizeMouseAtPoint(centre.x, centre.y, opts, win);
289 * Select <tt><option></tt> element in a <tt><select></tt>
292 * Because the dropdown list of select elements are implemented using
293 * native widget technology, our trusted synthesised events are not able
294 * to reach them. Dropdowns are instead handled mimicking DOM events,
295 * which for obvious reasons is not ideal, but at the current point in
296 * time considered to be good enough.
298 * @param {HTMLOptionElement} el
299 * Option element to select.
301 * @throws {TypeError}
302 * If <var>el</var> is a XUL element or not an <tt><option></tt>
305 * If unable to find <var>el</var>'s parent <tt><select></tt>
308 interaction.selectOption = function (el) {
309 if (lazy.dom.isXULElement(el)) {
310 throw new TypeError("XUL dropdowns not supported");
312 if (el.localName != "option") {
313 throw new TypeError(lazy.pprint`Expected <option> element, got ${el}`);
316 let containerEl = lazy.dom.getContainer(el);
318 lazy.event.mouseover(containerEl);
319 lazy.event.mousemove(containerEl);
320 lazy.event.mousedown(containerEl);
324 // Clicking <option> in <select> should not be deselected if selected.
325 // However, clicking one in a <select multiple> should toggle
326 // selectedness the way holding down Control works.
327 if (containerEl.multiple) {
328 el.selected = !el.selected;
329 } else if (!el.selected) {
332 lazy.event.input(containerEl);
333 lazy.event.change(containerEl);
336 lazy.event.mouseup(containerEl);
337 lazy.event.click(containerEl);
342 * Clears the form control or the editable element, if required.
344 * Before clearing the element, it will attempt to scroll it into
345 * view if it is not already in the viewport. An error is raised
346 * if the element cannot be brought into view.
348 * If the element is a submittable form control and it is empty
349 * (it has no value or it has no files associated with it, in the
350 * case it is a <code><input type=file></code> element) or
351 * it is an editing host and its <code>innerHTML</code> content IDL
352 * attribute is empty, this function acts as a no-op.
354 * @param {Element} el
357 * @throws {InvalidElementStateError}
358 * If element is disabled, read-only, non-editable, not a submittable
359 * element or not an editing host, or cannot be scrolled into view.
361 interaction.clearElement = function (el) {
362 if (lazy.dom.isDisabled(el)) {
363 throw new lazy.error.InvalidElementStateError(
364 lazy.pprint`Element is disabled: ${el}`
367 if (lazy.dom.isReadOnly(el)) {
368 throw new lazy.error.InvalidElementStateError(
369 lazy.pprint`Element is read-only: ${el}`
372 if (!lazy.dom.isEditable(el)) {
373 throw new lazy.error.InvalidElementStateError(
374 lazy.pprint`Unable to clear element that cannot be edited: ${el}`
378 if (!lazy.dom.isInView(el)) {
379 lazy.dom.scrollIntoView(el);
381 if (!lazy.dom.isInView(el)) {
382 throw new lazy.error.ElementNotInteractableError(
383 lazy.pprint`Element ${el} could not be scrolled into view`
387 if (lazy.dom.isEditingHost(el)) {
388 clearContentEditableElement(el);
390 clearResettableElement(el);
394 function clearContentEditableElement(el) {
395 if (el.innerHTML === "") {
403 function clearResettableElement(el) {
404 if (!lazy.dom.isMutableFormControl(el)) {
405 throw new lazy.error.InvalidElementStateError(
406 lazy.pprint`Not an editable form control: ${el}`
413 isEmpty = !el.files.length;
417 isEmpty = el.value === "";
421 if (el.validity.valid && isEmpty) {
427 lazy.event.change(el);
432 * Waits until the event loop has spun enough times to process the
433 * DOM events generated by clicking an element, or until the document
436 * @param {Element} el
437 * Element that is expected to receive the click.
440 * Promise is resolved once <var>el</var> has been clicked
441 * (its <code>click</code> event fires), the document is unloaded,
442 * or a 500 ms timeout is reached.
444 interaction.flushEventLoop = async function (el) {
445 const win = el.ownerGlobal;
446 let unloadEv, clickEv;
448 let spinEventLoop = resolve => {
451 lazy.logger.trace(`Received DOM event click for ${event.target}`);
455 lazy.setTimeout(resolve, 0);
459 win.addEventListener("unload", unloadEv, { mozSystemGroup: true });
460 el.addEventListener("click", clickEv, { mozSystemGroup: true });
462 let removeListeners = () => {
463 // only one event fires
464 win.removeEventListener("unload", unloadEv);
465 el.removeEventListener("click", clickEv);
468 return new lazy.TimedPromise(spinEventLoop, {
471 }).then(removeListeners);
475 * If <var>el<var> is a textual form control, or is contenteditable,
476 * and no previous selection state exists, move the caret to the end
477 * of the form control.
479 * The element has to be a <code><input type=text></code> or
480 * <code><textarea></code> element, or have the contenteditable
481 * attribute set, for the cursor to be moved.
483 * @param {Element} el
484 * Element to potential move the caret in.
486 interaction.moveCaretToEnd = function (el) {
487 if (!lazy.dom.isDOMElement(el)) {
491 let isTextarea = el.localName == "textarea";
492 let isInputText = el.localName == "input" && el.type == "text";
494 if (isTextarea || isInputText) {
495 if (el.selectionEnd == 0) {
496 let len = el.value.length;
497 el.setSelectionRange(len, len);
499 } else if (el.isContentEditable) {
500 let selection = getWindow(el).getSelection();
501 selection.setPosition(el, el.childNodes.length);
506 * Performs checks if <var>el</var> is keyboard-interactable.
508 * To decide if an element is keyboard-interactable various properties,
509 * and computed CSS styles have to be evaluated. Whereby it has to be taken
510 * into account that the element can be part of a container (eg. option),
511 * and as such the container has to be checked instead.
513 * @param {Element} el
517 * True if element is keyboard-interactable, false otherwise.
519 interaction.isKeyboardInteractable = function (el) {
520 const win = getWindow(el);
522 // body and document element are always keyboard-interactable
523 if (el.localName === "body" || el === win.document.documentElement) {
527 // context menu popups do not take the focus from the document.
528 const menuPopup = el.closest("menupopup");
530 if (menuPopup.state !== "open") {
531 // closed menupopups are not keyboard interactable.
535 const menuItem = el.closest("menuitem");
537 // hidden or disabled menu items are not keyboard interactable.
538 return !menuItem.disabled && !menuItem.hidden;
544 return Services.focus.elementIsFocusable(el, 0);
548 * Updates an `<input type=file>`'s file list with given `paths`.
550 * Hereby will the file list be appended with `paths` if the
551 * element allows multiple files. Otherwise the list will be
554 * @param {HTMLInputElement} el
555 * An `input type=file` element.
556 * @param {Array.<string>} paths
557 * List of full paths to any of the files to be uploaded.
559 * @throws {InvalidArgumentError}
560 * If `path` doesn't exist.
562 interaction.uploadFiles = async function (el, paths) {
565 if (el.hasAttribute("multiple")) {
566 // for multiple file uploads new files will be appended
567 files = Array.prototype.slice.call(el.files);
568 } else if (paths.length > 1) {
569 throw new lazy.error.InvalidArgumentError(
570 lazy.pprint`Element ${el} doesn't accept multiple files`
574 for (let path of paths) {
578 file = await File.createFromFileName(path);
580 throw new lazy.error.InvalidArgumentError("File not found: " + path);
586 el.mozSetFileArray(files);
590 * Sets a form element's value.
592 * @param {DOMElement} el
593 * An form element, e.g. input, textarea, etc.
594 * @param {string} value
595 * The value to be set.
597 * @throws {TypeError}
598 * If <var>el</var> is not an supported form element.
600 interaction.setFormControlValue = function (el, value) {
601 if (!COMMON_FORM_CONTROLS.has(el.localName)) {
602 throw new TypeError("This function is for form elements only");
607 if (INPUT_TYPES_NO_EVENT.has(el.type)) {
611 lazy.event.input(el);
612 lazy.event.change(el);
616 * Send keys to element.
618 * @param {DOMElement|XULElement} el
619 * Element to send key events to.
620 * @param {Array.<string>} value
621 * Sequence of keystrokes to send to the element.
622 * @param {object=} options
623 * @param {boolean=} options.strictFileInteractability
624 * Run interactability checks on `<input type=file>` elements.
625 * @param {boolean=} options.accessibilityChecks
626 * Enforce strict accessibility tests.
627 * @param {boolean=} options.webdriverClick
628 * Use WebDriver specification compatible interactability definition.
630 interaction.sendKeysToElement = async function (
634 strictFileInteractability = false,
635 accessibilityChecks = false,
636 webdriverClick = false,
639 const a11y = lazy.accessibility.get(accessibilityChecks);
641 if (webdriverClick) {
642 await webdriverSendKeysToElement(
646 strictFileInteractability
649 await legacySendKeysToElement(el, value, a11y);
653 async function webdriverSendKeysToElement(
657 strictFileInteractability
659 const win = getWindow(el);
661 if (el.type !== "file" || strictFileInteractability) {
662 let containerEl = lazy.dom.getContainer(el);
664 lazy.dom.scrollIntoView(containerEl);
666 // TODO: Wait for element to be keyboard-interactible
667 if (!interaction.isKeyboardInteractable(containerEl)) {
668 throw new lazy.error.ElementNotInteractableError(
669 lazy.pprint`Element ${el} is not reachable by keyboard`
673 if (win.document.activeElement !== containerEl) {
675 // This validates the correct element types internally
676 interaction.moveCaretToEnd(containerEl);
680 let acc = await a11y.assertAccessible(el, true);
681 a11y.assertActionable(acc, el);
683 if (el.type == "file") {
684 let paths = value.split("\n");
685 await interaction.uploadFiles(el, paths);
687 lazy.event.input(el);
688 lazy.event.change(el);
689 } else if (el.type == "date" || el.type == "time") {
690 interaction.setFormControlValue(el, value);
692 lazy.event.sendKeys(value, win);
696 async function legacySendKeysToElement(el, value, a11y) {
697 const win = getWindow(el);
699 if (el.type == "file") {
701 await interaction.uploadFiles(el, [value]);
703 lazy.event.input(el);
704 lazy.event.change(el);
705 } else if (el.type == "date" || el.type == "time") {
706 interaction.setFormControlValue(el, value);
708 let visibilityCheckEl = el;
709 if (el.localName == "option") {
710 visibilityCheckEl = lazy.dom.getContainer(el);
713 if (!(await lazy.dom.isVisible(visibilityCheckEl))) {
714 throw new lazy.error.ElementNotInteractableError(
715 "Element is not visible"
719 let acc = await a11y.assertAccessible(el, true);
720 a11y.assertActionable(acc, el);
722 interaction.moveCaretToEnd(el);
724 lazy.event.sendKeys(value, win);
729 * Determine the element displayedness of an element.
731 * @param {DOMElement|XULElement} el
732 * Element to determine displayedness of.
733 * @param {boolean=} [strict=false] strict
734 * Enforce strict accessibility tests.
737 * True if element is displayed, false otherwise.
739 interaction.isElementDisplayed = async function (el, strict = false) {
740 let win = getWindow(el);
741 let displayed = await lazy.atom.isElementDisplayed(el, win);
743 let a11y = lazy.accessibility.get(strict);
744 return a11y.assertAccessible(el).then(acc => {
745 a11y.assertVisible(acc, el, displayed);
751 * Check if element is enabled.
753 * @param {DOMElement|XULElement} el
754 * Element to test if is enabled.
757 * True if enabled, false otherwise.
759 interaction.isElementEnabled = async function (el, strict = false) {
761 let win = getWindow(el);
763 if (lazy.dom.isXULElement(el)) {
764 // check if XUL element supports disabled attribute
765 if (DISABLED_ATTRIBUTE_SUPPORTED_XUL.has(el.tagName.toUpperCase())) {
767 el.hasAttribute("disabled") &&
768 el.getAttribute("disabled") === "true"
774 ["application/xml", "text/xml"].includes(win.document.contentType)
778 enabled = await lazy.atom.isElementEnabled(el, win);
781 let a11y = lazy.accessibility.get(strict);
782 return a11y.assertAccessible(el).then(acc => {
783 a11y.assertEnabled(acc, el, enabled);
789 * Determines if the referenced element is selected or not, with
790 * an additional accessibility check if <var>strict</var> is true.
792 * This operation only makes sense on input elements of the checkbox-
793 * and radio button states, and option elements.
795 * @param {(DOMElement|XULElement)} el
796 * Element to test if is selected.
797 * @param {boolean=} [strict=false] strict
798 * Enforce strict accessibility tests.
801 * True if element is selected, false otherwise.
803 * @throws {ElementNotAccessibleError}
804 * If <var>el</var> is not accessible when <var>strict</var> is true.
806 interaction.isElementSelected = function (el, strict = false) {
807 let selected = lazy.dom.isSelected(el);
809 let a11y = lazy.accessibility.get(strict);
810 return a11y.assertAccessible(el).then(acc => {
811 a11y.assertSelected(acc, el, selected);
816 function getWindow(el) {
817 // eslint-disable-next-line mozilla/use-ownerGlobal
818 return el.ownerDocument.defaultView;