no bug - Bumping Firefox l10n changesets r=release a=l10n-bump DONTBUILD CLOSED TREE
[gecko.git] / remote / marionette / interaction.sys.mjs
blobd710f2eb46cadddaa6a457d23314dc77161a0309
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: "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",
20 });
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", () => {
29   try {
30     return Cc["@mozilla.org/widget/dragservice;1"].getService(
31       Ci.nsIDragService
32     );
33   } catch (e) {
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
37     // drag session.
38     return null;
39   }
40 });
42 /** XUL elements that support disabled attribute. */
43 const DISABLED_ATTRIBUTE_SUPPORTED_XUL = new Set([
44   "ARROWSCROLLBOX",
45   "BUTTON",
46   "CHECKBOX",
47   "COMMAND",
48   "DESCRIPTION",
49   "KEY",
50   "KEYSET",
51   "LABEL",
52   "MENU",
53   "MENUITEM",
54   "MENULIST",
55   "MENUSEPARATOR",
56   "RADIO",
57   "RADIOGROUP",
58   "RICHLISTBOX",
59   "RICHLISTITEM",
60   "TAB",
61   "TABS",
62   "TOOLBARBUTTON",
63   "TREE",
64 ]);
66 /**
67  * Common form controls that user can change the value property
68  * interactively.
69  */
70 const COMMON_FORM_CONTROLS = new Set(["input", "textarea", "select"]);
72 /**
73  * Input elements that do not fire <tt>input</tt> and <tt>change</tt>
74  * events when value property changes.
75  */
76 const INPUT_TYPES_NO_EVENT = new Set([
77   "checkbox",
78   "radio",
79   "file",
80   "hidden",
81   "image",
82   "reset",
83   "button",
84   "submit",
85 ]);
87 /** @namespace */
88 export const interaction = {};
90 /**
91  * Interact with an element by clicking it.
92  *
93  * The element is scrolled into view before visibility- or interactability
94  * checks are performed.
95  *
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}
106  * is returned.
108  * @param {(DOMElement|XULElement)} el
109  *     Element to click.
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.
125  */
126 interaction.clickElement = async function (
127   el,
128   strict = false,
129   specCompat = false
130 ) {
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);
136   } else {
137     lazy.logger.trace(`Using non spec-compatible element click`);
138     await seleniumClickElement(el, a11y);
139   }
142 async function webdriverClickElement(el, a11y) {
143   const win = getWindow(el);
145   // step 3
146   if (el.localName == "input" && el.type == "file") {
147     throw new lazy.error.InvalidArgumentError(
148       "Cannot click <input type=file> elements"
149     );
150   }
152   let containerEl = lazy.dom.getContainer(el);
154   // step 4
155   if (!lazy.dom.isInView(containerEl)) {
156     lazy.dom.scrollIntoView(containerEl);
157   }
159   // step 5
160   // TODO(ato): wait for containerEl to be in view
162   // step 6
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`
168     );
169   }
171   // step 7
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(
177       null,
178       {},
179       containerEl,
180       clickPoint
181     );
182   }
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);
189   // step 8
190   if (el.localName == "option") {
191     interaction.selectOption(el);
192   } else {
193     // Synthesize a pointerMove action.
194     lazy.event.synthesizeMouseAtPoint(
195       clickPoint.x,
196       clickPoint.y,
197       {
198         type: "mousemove",
199         allowToHandleDragDrop: true,
200       },
201       win
202     );
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(
209         clickPoint.x,
210         clickPoint.y,
211         {
212           type: "mouseup",
213           allowToHandleDragDrop: true,
214         },
215         win
216       );
217     } else {
218       // step 9
219       let clicked = interaction.flushEventLoop(containerEl);
221       // Synthesize a pointerDown + pointerUp action.
222       lazy.event.synthesizeMouseAtPoint(
223         clickPoint.x,
224         clickPoint.y,
225         { allowToHandleDragDrop: true },
226         win
227       );
229       await clicked;
230     }
231   }
233   // step 10
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");
243   }
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);
252   } else {
253     el.click();
254   }
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);
263   }
265   if (!(await lazy.dom.isVisible(visibilityCheckEl))) {
266     throw new lazy.error.ElementNotInteractableError();
267   }
269   if (!(await lazy.atom.isElementEnabled(el, win))) {
270     throw new lazy.error.InvalidElementStateError("Element is not enabled");
271   }
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);
280   } else {
281     let rects = el.getClientRects();
282     let centre = lazy.dom.getInViewCentrePoint(rects[0], win);
283     let opts = {};
284     lazy.event.synthesizeMouseAtPoint(centre.x, centre.y, opts, win);
285   }
289  * Select <tt>&lt;option&gt;</tt> element in a <tt>&lt;select&gt;</tt>
290  * list.
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>&lt;option&gt;</tt>
303  *     element.
304  * @throws {Error}
305  *     If unable to find <var>el</var>'s parent <tt>&lt;select&gt;</tt>
306  *     element.
307  */
308 interaction.selectOption = function (el) {
309   if (lazy.dom.isXULElement(el)) {
310     throw new TypeError("XUL dropdowns not supported");
311   }
312   if (el.localName != "option") {
313     throw new TypeError(lazy.pprint`Expected <option> element, got ${el}`);
314   }
316   let containerEl = lazy.dom.getContainer(el);
318   lazy.event.mouseover(containerEl);
319   lazy.event.mousemove(containerEl);
320   lazy.event.mousedown(containerEl);
321   containerEl.focus();
323   if (!el.disabled) {
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) {
330       el.selected = true;
331     }
332     lazy.event.input(containerEl);
333     lazy.event.change(containerEl);
334   }
336   lazy.event.mouseup(containerEl);
337   lazy.event.click(containerEl);
338   containerEl.blur();
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>&lt;input type=file&gt;</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
355  *     Element to clear.
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.
360  */
361 interaction.clearElement = function (el) {
362   if (lazy.dom.isDisabled(el)) {
363     throw new lazy.error.InvalidElementStateError(
364       lazy.pprint`Element is disabled: ${el}`
365     );
366   }
367   if (lazy.dom.isReadOnly(el)) {
368     throw new lazy.error.InvalidElementStateError(
369       lazy.pprint`Element is read-only: ${el}`
370     );
371   }
372   if (!lazy.dom.isEditable(el)) {
373     throw new lazy.error.InvalidElementStateError(
374       lazy.pprint`Unable to clear element that cannot be edited: ${el}`
375     );
376   }
378   if (!lazy.dom.isInView(el)) {
379     lazy.dom.scrollIntoView(el);
380   }
381   if (!lazy.dom.isInView(el)) {
382     throw new lazy.error.ElementNotInteractableError(
383       lazy.pprint`Element ${el} could not be scrolled into view`
384     );
385   }
387   if (lazy.dom.isEditingHost(el)) {
388     clearContentEditableElement(el);
389   } else {
390     clearResettableElement(el);
391   }
394 function clearContentEditableElement(el) {
395   if (el.innerHTML === "") {
396     return;
397   }
398   el.focus();
399   el.innerHTML = "";
400   el.blur();
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}`
407     );
408   }
410   let isEmpty;
411   switch (el.type) {
412     case "file":
413       isEmpty = !el.files.length;
414       break;
416     default:
417       isEmpty = el.value === "";
418       break;
419   }
421   if (el.validity.valid && isEmpty) {
422     return;
423   }
425   el.focus();
426   el.value = "";
427   lazy.event.change(el);
428   el.blur();
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
434  * is unloaded.
436  * @param {Element} el
437  *     Element that is expected to receive the click.
439  * @returns {Promise}
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.
443  */
444 interaction.flushEventLoop = async function (el) {
445   const win = el.ownerGlobal;
446   let unloadEv, clickEv;
448   let spinEventLoop = resolve => {
449     unloadEv = resolve;
450     clickEv = event => {
451       lazy.logger.trace(`Received DOM event click for ${event.target}`);
452       if (win.closed) {
453         resolve();
454       } else {
455         lazy.setTimeout(resolve, 0);
456       }
457     };
459     win.addEventListener("unload", unloadEv, { mozSystemGroup: true });
460     el.addEventListener("click", clickEv, { mozSystemGroup: true });
461   };
462   let removeListeners = () => {
463     // only one event fires
464     win.removeEventListener("unload", unloadEv);
465     el.removeEventListener("click", clickEv);
466   };
468   return new lazy.TimedPromise(spinEventLoop, {
469     timeout: 500,
470     throws: null,
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>&lt;input type=text&gt;</code> or
480  * <code>&lt;textarea&gt;</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.
485  */
486 interaction.moveCaretToEnd = function (el) {
487   if (!lazy.dom.isDOMElement(el)) {
488     return;
489   }
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);
498     }
499   } else if (el.isContentEditable) {
500     let selection = getWindow(el).getSelection();
501     selection.setPosition(el, el.childNodes.length);
502   }
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
514  *     Element to check.
516  * @returns {boolean}
517  *     True if element is keyboard-interactable, false otherwise.
518  */
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) {
524     return true;
525   }
527   // context menu popups do not take the focus from the document.
528   const menuPopup = el.closest("menupopup");
529   if (menuPopup) {
530     if (menuPopup.state !== "open") {
531       // closed menupopups are not keyboard interactable.
532       return false;
533     }
535     const menuItem = el.closest("menuitem");
536     if (menuItem) {
537       // hidden or disabled menu items are not keyboard interactable.
538       return !menuItem.disabled && !menuItem.hidden;
539     }
541     return true;
542   }
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
552  * replaced.
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.
561  */
562 interaction.uploadFiles = async function (el, paths) {
563   let files = [];
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`
571     );
572   }
574   for (let path of paths) {
575     let file;
577     try {
578       file = await File.createFromFileName(path);
579     } catch (e) {
580       throw new lazy.error.InvalidArgumentError("File not found: " + path);
581     }
583     files.push(file);
584   }
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.
599  */
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");
603   }
605   el.value = value;
607   if (INPUT_TYPES_NO_EVENT.has(el.type)) {
608     return;
609   }
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.
629  */
630 interaction.sendKeysToElement = async function (
631   el,
632   value,
633   {
634     strictFileInteractability = false,
635     accessibilityChecks = false,
636     webdriverClick = false,
637   } = {}
638 ) {
639   const a11y = lazy.accessibility.get(accessibilityChecks);
641   if (webdriverClick) {
642     await webdriverSendKeysToElement(
643       el,
644       value,
645       a11y,
646       strictFileInteractability
647     );
648   } else {
649     await legacySendKeysToElement(el, value, a11y);
650   }
653 async function webdriverSendKeysToElement(
654   el,
655   value,
656   a11y,
657   strictFileInteractability
658 ) {
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`
670       );
671     }
673     if (win.document.activeElement !== containerEl) {
674       containerEl.focus();
675       // This validates the correct element types internally
676       interaction.moveCaretToEnd(containerEl);
677     }
678   }
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);
691   } else {
692     lazy.event.sendKeys(value, win);
693   }
696 async function legacySendKeysToElement(el, value, a11y) {
697   const win = getWindow(el);
699   if (el.type == "file") {
700     el.focus();
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);
707   } else {
708     let visibilityCheckEl = el;
709     if (el.localName == "option") {
710       visibilityCheckEl = lazy.dom.getContainer(el);
711     }
713     if (!(await lazy.dom.isVisible(visibilityCheckEl))) {
714       throw new lazy.error.ElementNotInteractableError(
715         "Element is not visible"
716       );
717     }
719     let acc = await a11y.assertAccessible(el, true);
720     a11y.assertActionable(acc, el);
722     interaction.moveCaretToEnd(el);
723     el.focus();
724     lazy.event.sendKeys(value, win);
725   }
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.
736  * @returns {boolean}
737  *     True if element is displayed, false otherwise.
738  */
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);
746     return displayed;
747   });
751  * Check if element is enabled.
753  * @param {DOMElement|XULElement} el
754  *     Element to test if is enabled.
756  * @returns {boolean}
757  *     True if enabled, false otherwise.
758  */
759 interaction.isElementEnabled = async function (el, strict = false) {
760   let enabled = true;
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())) {
766       if (
767         el.hasAttribute("disabled") &&
768         el.getAttribute("disabled") === "true"
769       ) {
770         enabled = false;
771       }
772     }
773   } else if (
774     ["application/xml", "text/xml"].includes(win.document.contentType)
775   ) {
776     enabled = false;
777   } else {
778     enabled = await lazy.atom.isElementEnabled(el, win);
779   }
781   let a11y = lazy.accessibility.get(strict);
782   return a11y.assertAccessible(el).then(acc => {
783     a11y.assertEnabled(acc, el, enabled);
784     return enabled;
785   });
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.
800  * @returns {boolean}
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.
805  */
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);
812     return selected;
813   });
816 function getWindow(el) {
817   // eslint-disable-next-line mozilla/use-ownerGlobal
818   return el.ownerDocument.defaultView;