Backed out changeset b71c8c052463 (bug 1943846) for causing mass failures. CLOSED...
[gecko.git] / remote / marionette / interaction.sys.mjs
blob7f92096de40713327f61ea843e2912697973e4a6
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 */
7 const lazy = {};
9 ChromeUtils.defineESModuleGetters(lazy, {
10   setTimeout: "resource://gre/modules/Timer.sys.mjs",
12   accessibility:
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",
21 });
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", () => {
30   try {
31     return Cc["@mozilla.org/widget/dragservice;1"].getService(
32       Ci.nsIDragService
33     );
34   } catch (e) {
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
38     // drag session.
39     return null;
40   }
41 });
43 /** XUL elements that support disabled attribute. */
44 const DISABLED_ATTRIBUTE_SUPPORTED_XUL = new Set([
45   "ARROWSCROLLBOX",
46   "BUTTON",
47   "CHECKBOX",
48   "COMMAND",
49   "DESCRIPTION",
50   "KEY",
51   "KEYSET",
52   "LABEL",
53   "MENU",
54   "MENUITEM",
55   "MENULIST",
56   "MENUSEPARATOR",
57   "RADIO",
58   "RADIOGROUP",
59   "RICHLISTBOX",
60   "RICHLISTITEM",
61   "TAB",
62   "TABS",
63   "TOOLBARBUTTON",
64   "TREE",
65 ]);
67 /**
68  * Common form controls that user can change the value property
69  * interactively.
70  */
71 const COMMON_FORM_CONTROLS = new Set(["input", "textarea", "select"]);
73 /**
74  * Input elements that do not fire <tt>input</tt> and <tt>change</tt>
75  * events when value property changes.
76  */
77 const INPUT_TYPES_NO_EVENT = new Set([
78   "checkbox",
79   "radio",
80   "file",
81   "hidden",
82   "image",
83   "reset",
84   "button",
85   "submit",
86 ]);
88 /** @namespace */
89 export const interaction = {};
91 /**
92  * Interact with an element by clicking it.
93  *
94  * The element is scrolled into view before visibility- or interactability
95  * checks are performed.
96  *
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}
107  * is returned.
109  * @param {(DOMElement|XULElement)} el
110  *     Element to click.
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.
126  */
127 interaction.clickElement = async function (
128   el,
129   strict = false,
130   specCompat = false
131 ) {
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);
137   } else {
138     lazy.logger.trace(`Using non spec-compatible element click`);
139     await seleniumClickElement(el, a11y);
140   }
143 async function webdriverClickElement(el, a11y) {
144   const win = getWindow(el);
146   // step 3
147   if (el.localName == "input" && el.type == "file") {
148     throw new lazy.error.InvalidArgumentError(
149       "Cannot click <input type=file> elements"
150     );
151   }
153   let containerEl = lazy.dom.getContainer(el);
155   // step 4
156   if (!lazy.dom.isInView(containerEl)) {
157     lazy.dom.scrollIntoView(containerEl);
158   }
160   // step 5
161   // TODO(ato): wait for containerEl to be in view
163   // step 6
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`
169     );
170   }
172   // step 7
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(
178       null,
179       {},
180       containerEl,
181       clickPoint
182     );
183   }
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);
190   // step 8
191   if (el.localName == "option") {
192     interaction.selectOption(el);
193   } else {
194     // Synthesize a pointerMove action.
195     lazy.event.synthesizeMouseAtPoint(
196       clickPoint.x,
197       clickPoint.y,
198       {
199         type: "mousemove",
200         allowToHandleDragDrop: true,
201       },
202       win
203     );
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(
210         clickPoint.x,
211         clickPoint.y,
212         {
213           type: "mouseup",
214           allowToHandleDragDrop: true,
215         },
216         win
217       );
218     } else {
219       // step 9
220       let clicked = interaction.flushEventLoop(containerEl);
222       // Synthesize a pointerDown + pointerUp action.
223       lazy.event.synthesizeMouseAtPoint(
224         clickPoint.x,
225         clickPoint.y,
226         { allowToHandleDragDrop: true },
227         win
228       );
230       await clicked;
231     }
232   }
234   // step 10
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");
242   }
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);
251   } else {
252     el.click();
253   }
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);
262   }
264   if (!(await lazy.dom.isVisible(visibilityCheckEl))) {
265     throw new lazy.error.ElementNotInteractableError();
266   }
268   if (!(await lazy.dom.isEnabled(el))) {
269     throw new lazy.error.InvalidElementStateError("Element is not enabled");
270   }
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);
279   } else {
280     let rects = el.getClientRects();
281     let centre = lazy.dom.getInViewCentrePoint(rects[0], win);
282     let opts = {};
283     lazy.event.synthesizeMouseAtPoint(centre.x, centre.y, opts, win);
284   }
288  * Select <tt>&lt;option&gt;</tt> element in a <tt>&lt;select&gt;</tt>
289  * list.
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>&lt;option&gt;</tt>
302  *     element.
303  * @throws {Error}
304  *     If unable to find <var>el</var>'s parent <tt>&lt;select&gt;</tt>
305  *     element.
306  */
307 interaction.selectOption = function (el) {
308   if (lazy.dom.isXULElement(el)) {
309     throw new TypeError("XUL dropdowns not supported");
310   }
311   if (el.localName != "option") {
312     throw new TypeError(lazy.pprint`Expected <option> element, got ${el}`);
313   }
315   let containerEl = lazy.dom.getContainer(el);
317   lazy.event.mouseover(containerEl);
318   lazy.event.mousemove(containerEl);
319   lazy.event.mousedown(containerEl);
320   containerEl.focus();
322   if (!el.disabled) {
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) {
329       el.selected = true;
330     }
331     lazy.event.input(containerEl);
332     lazy.event.change(containerEl);
333   }
335   lazy.event.mouseup(containerEl);
336   lazy.event.click(containerEl);
337   containerEl.blur();
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>&lt;input type=file&gt;</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
354  *     Element to clear.
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.
359  */
360 interaction.clearElement = function (el) {
361   if (lazy.dom.isDisabled(el)) {
362     throw new lazy.error.InvalidElementStateError(
363       lazy.pprint`Element is disabled: ${el}`
364     );
365   }
366   if (lazy.dom.isReadOnly(el)) {
367     throw new lazy.error.InvalidElementStateError(
368       lazy.pprint`Element is read-only: ${el}`
369     );
370   }
371   if (!lazy.dom.isEditable(el)) {
372     throw new lazy.error.InvalidElementStateError(
373       lazy.pprint`Unable to clear element that cannot be edited: ${el}`
374     );
375   }
377   if (!lazy.dom.isInView(el)) {
378     lazy.dom.scrollIntoView(el);
379   }
380   if (!lazy.dom.isInView(el)) {
381     throw new lazy.error.ElementNotInteractableError(
382       lazy.pprint`Element ${el} could not be scrolled into view`
383     );
384   }
386   if (lazy.dom.isEditingHost(el)) {
387     clearContentEditableElement(el);
388   } else {
389     clearResettableElement(el);
390   }
393 function clearContentEditableElement(el) {
394   if (el.innerHTML === "") {
395     return;
396   }
397   el.focus();
398   el.innerHTML = "";
399   el.blur();
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}`
406     );
407   }
409   let isEmpty;
410   switch (el.type) {
411     case "file":
412       isEmpty = !el.files.length;
413       break;
415     default:
416       isEmpty = el.value === "";
417       break;
418   }
420   if (el.validity.valid && isEmpty) {
421     return;
422   }
424   el.focus();
425   el.value = "";
426   lazy.event.change(el);
427   el.blur();
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
433  * is unloaded.
435  * @param {Element} el
436  *     Element that is expected to receive the click.
438  * @returns {Promise}
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.
442  */
443 interaction.flushEventLoop = async function (el) {
444   const win = el.ownerGlobal;
445   let unloadEv, clickEv;
447   let spinEventLoop = resolve => {
448     unloadEv = resolve;
449     clickEv = event => {
450       lazy.logger.trace(`Received DOM event click for ${event.target}`);
451       if (win.closed) {
452         resolve();
453       } else {
454         lazy.setTimeout(resolve, 0);
455       }
456     };
458     win.addEventListener("unload", unloadEv, { mozSystemGroup: true });
459     el.addEventListener("click", clickEv, { mozSystemGroup: true });
460   };
461   let removeListeners = () => {
462     // only one event fires
463     win.removeEventListener("unload", unloadEv);
464     el.removeEventListener("click", clickEv);
465   };
467   return new lazy.TimedPromise(spinEventLoop, {
468     timeout: 500,
469     throws: null,
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>&lt;input type=text&gt;</code> or
479  * <code>&lt;textarea&gt;</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.
484  */
485 interaction.moveCaretToEnd = function (el) {
486   if (!lazy.dom.isDOMElement(el)) {
487     return;
488   }
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);
497     }
498   } else if (el.isContentEditable) {
499     let selection = getWindow(el).getSelection();
500     selection.setPosition(el, el.childNodes.length);
501   }
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
513  *     Element to check.
515  * @returns {boolean}
516  *     True if element is keyboard-interactable, false otherwise.
517  */
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) {
523     return true;
524   }
526   // context menu popups do not take the focus from the document.
527   const menuPopup = el.closest("menupopup");
528   if (menuPopup) {
529     if (menuPopup.state !== "open") {
530       // closed menupopups are not keyboard interactable.
531       return false;
532     }
534     const menuItem = el.closest("menuitem");
535     if (menuItem) {
536       // hidden or disabled menu items are not keyboard interactable.
537       return !menuItem.disabled && !menuItem.hidden;
538     }
540     return true;
541   }
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
551  * replaced.
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.
560  */
561 interaction.uploadFiles = async function (el, paths) {
562   let files = [];
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`
570     );
571   }
573   for (let path of paths) {
574     let file;
576     try {
577       file = await File.createFromFileName(path);
578     } catch (e) {
579       throw new lazy.error.InvalidArgumentError("File not found: " + path);
580     }
582     files.push(file);
583   }
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.
598  */
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");
602   }
604   el.value = value;
606   if (INPUT_TYPES_NO_EVENT.has(el.type)) {
607     return;
608   }
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.
628  */
629 interaction.sendKeysToElement = async function (
630   el,
631   value,
632   {
633     strictFileInteractability = false,
634     accessibilityChecks = false,
635     webdriverClick = false,
636   } = {}
637 ) {
638   const a11y = lazy.accessibility.get(accessibilityChecks);
640   if (webdriverClick) {
641     await webdriverSendKeysToElement(
642       el,
643       value,
644       a11y,
645       strictFileInteractability
646     );
647   } else {
648     await legacySendKeysToElement(el, value, a11y);
649   }
652 async function webdriverSendKeysToElement(
653   el,
654   value,
655   a11y,
656   strictFileInteractability
657 ) {
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);
665     }
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`
671       );
672     }
674     if (win.document.activeElement !== containerEl) {
675       containerEl.focus();
676       // This validates the correct element types internally
677       interaction.moveCaretToEnd(containerEl);
678     }
679   }
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);
692   } else {
693     lazy.event.sendKeys(value, win);
694   }
697 async function legacySendKeysToElement(el, value, a11y) {
698   const win = getWindow(el);
700   if (el.type == "file") {
701     el.focus();
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);
708   } else {
709     let visibilityCheckEl = el;
710     if (el.localName == "option") {
711       visibilityCheckEl = lazy.dom.getContainer(el);
712     }
714     if (!(await lazy.dom.isVisible(visibilityCheckEl))) {
715       throw new lazy.error.ElementNotInteractableError(
716         "Element is not visible"
717       );
718     }
720     let acc = await a11y.assertAccessible(el, true);
721     a11y.assertActionable(acc, el);
723     interaction.moveCaretToEnd(el);
724     el.focus();
725     lazy.event.sendKeys(value, win);
726   }
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.
737  * @returns {boolean}
738  *     True if element is displayed, false otherwise.
739  */
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);
747     return displayed;
748   });
752  * Check if element is enabled.
754  * @param {DOMElement|XULElement} el
755  *     Element to test if is enabled.
757  * @returns {boolean}
758  *     True if enabled, false otherwise.
759  */
760 interaction.isElementEnabled = async function (el, strict = false) {
761   let enabled = true;
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())) {
767       if (
768         el.hasAttribute("disabled") &&
769         el.getAttribute("disabled") === "true"
770       ) {
771         enabled = false;
772       }
773     }
774   } else if (
775     ["application/xml", "text/xml"].includes(win.document.contentType)
776   ) {
777     enabled = false;
778   } else {
779     enabled = await lazy.dom.isEnabled(el);
780   }
782   let a11y = lazy.accessibility.get(strict);
783   return a11y.assertAccessible(el).then(acc => {
784     a11y.assertEnabled(acc, el, enabled);
785     return enabled;
786   });
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.
801  * @returns {boolean}
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.
806  */
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);
813     return selected;
814   });
817 function getWindow(el) {
818   // eslint-disable-next-line mozilla/use-ownerGlobal
819   return el.ownerDocument.defaultView;