interaction.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506
  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/. */
  4. "use strict";
  5. const {utils: Cu} = Components;
  6. Cu.import("chrome://marionette/content/accessibility.js");
  7. Cu.import("chrome://marionette/content/atom.js");
  8. Cu.import("chrome://marionette/content/error.js");
  9. Cu.import("chrome://marionette/content/element.js");
  10. Cu.import("chrome://marionette/content/event.js");
  11. Cu.importGlobalProperties(["File"]);
  12. this.EXPORTED_SYMBOLS = ["interaction"];
  13. /**
  14. * XUL elements that support disabled attribute.
  15. */
  16. const DISABLED_ATTRIBUTE_SUPPORTED_XUL = new Set([
  17. "ARROWSCROLLBOX",
  18. "BUTTON",
  19. "CHECKBOX",
  20. "COLORPICKER",
  21. "COMMAND",
  22. "DATEPICKER",
  23. "DESCRIPTION",
  24. "KEY",
  25. "KEYSET",
  26. "LABEL",
  27. "LISTBOX",
  28. "LISTCELL",
  29. "LISTHEAD",
  30. "LISTHEADER",
  31. "LISTITEM",
  32. "MENU",
  33. "MENUITEM",
  34. "MENULIST",
  35. "MENUSEPARATOR",
  36. "PREFERENCE",
  37. "RADIO",
  38. "RADIOGROUP",
  39. "RICHLISTBOX",
  40. "RICHLISTITEM",
  41. "SCALE",
  42. "TAB",
  43. "TABS",
  44. "TEXTBOX",
  45. "TIMEPICKER",
  46. "TOOLBARBUTTON",
  47. "TREE",
  48. ]);
  49. /**
  50. * XUL elements that support checked property.
  51. */
  52. const CHECKED_PROPERTY_SUPPORTED_XUL = new Set([
  53. "BUTTON",
  54. "CHECKBOX",
  55. "LISTITEM",
  56. "TOOLBARBUTTON",
  57. ]);
  58. /**
  59. * XUL elements that support selected property.
  60. */
  61. const SELECTED_PROPERTY_SUPPORTED_XUL = new Set([
  62. "LISTITEM",
  63. "MENU",
  64. "MENUITEM",
  65. "MENUSEPARATOR",
  66. "RADIO",
  67. "RICHLISTITEM",
  68. "TAB",
  69. ]);
  70. /**
  71. * Common form controls that user can change the value property interactively.
  72. */
  73. const COMMON_FORM_CONTROLS = new Set([
  74. "input",
  75. "textarea",
  76. "select",
  77. ]);
  78. /**
  79. * Input elements that do not fire "input" and "change" events when value
  80. * property changes.
  81. */
  82. const INPUT_TYPES_NO_EVENT = new Set([
  83. "checkbox",
  84. "radio",
  85. "file",
  86. "hidden",
  87. "image",
  88. "reset",
  89. "button",
  90. "submit",
  91. ]);
  92. this.interaction = {};
  93. /**
  94. * Interact with an element by clicking it.
  95. *
  96. * The element is scrolled into view before visibility- or interactability
  97. * checks are performed.
  98. *
  99. * Selenium-style visibility checks will be performed if |specCompat|
  100. * is false (default). Otherwise pointer-interactability checks will be
  101. * performed. If either of these fail an
  102. * {@code ElementNotInteractableError} is thrown.
  103. *
  104. * If |strict| is enabled (defaults to disabled), further accessibility
  105. * checks will be performed, and these may result in an
  106. * {@code ElementNotAccessibleError} being returned.
  107. *
  108. * When |el| is not enabled, an {@code InvalidElementStateError}
  109. * is returned.
  110. *
  111. * @param {DOMElement|XULElement} el
  112. * Element to click.
  113. * @param {boolean=} strict
  114. * Enforce strict accessibility tests.
  115. * @param {boolean=} specCompat
  116. * Use WebDriver specification compatible interactability definition.
  117. *
  118. * @throws {ElementNotInteractableError}
  119. * If either Selenium-style visibility check or
  120. * pointer-interactability check fails.
  121. * @throws {ElementClickInterceptedError}
  122. * If |el| is obscured by another element and a click would not hit,
  123. * in |specCompat| mode.
  124. * @throws {ElementNotAccessibleError}
  125. * If |strict| is true and element is not accessible.
  126. * @throws {InvalidElementStateError}
  127. * If |el| is not enabled.
  128. */
  129. interaction.clickElement = function* (el, strict = false, specCompat = false) {
  130. const a11y = accessibility.get(strict);
  131. if (specCompat) {
  132. yield webdriverClickElement(el, a11y);
  133. } else {
  134. yield seleniumClickElement(el, a11y);
  135. }
  136. };
  137. function* webdriverClickElement (el, a11y) {
  138. const win = getWindow(el);
  139. const doc = win.document;
  140. // step 3
  141. if (el.localName == "input" && el.type == "file") {
  142. throw new InvalidArgumentError(
  143. "Cannot click <input type=file> elements");
  144. }
  145. let containerEl = element.getContainer(el);
  146. // step 4
  147. if (!element.isInView(containerEl)) {
  148. element.scrollIntoView(containerEl);
  149. }
  150. // step 5
  151. // TODO(ato): wait for containerEl to be in view
  152. // step 6
  153. // if we cannot bring the container element into the viewport
  154. // there is no point in checking if it is pointer-interactable
  155. if (!element.isInView(containerEl)) {
  156. throw new ElementNotInteractableError(
  157. error.pprint`Element ${el} could not be scrolled into view`);
  158. }
  159. // step 7
  160. let rects = containerEl.getClientRects();
  161. let clickPoint = element.getInViewCentrePoint(rects[0], win);
  162. if (!element.isPointerInteractable(containerEl)) {
  163. throw new ElementClickInterceptedError(containerEl, clickPoint);
  164. }
  165. yield a11y.getAccessible(el, true).then(acc => {
  166. a11y.assertVisible(acc, el, true);
  167. a11y.assertEnabled(acc, el, true);
  168. a11y.assertActionable(acc, el);
  169. });
  170. // step 8
  171. // chrome elements
  172. if (element.isXULElement(el)) {
  173. if (el.localName == "option") {
  174. interaction.selectOption(el);
  175. } else {
  176. el.click();
  177. }
  178. // content elements
  179. } else {
  180. if (el.localName == "option") {
  181. interaction.selectOption(el);
  182. } else {
  183. event.synthesizeMouseAtPoint(clickPoint.x, clickPoint.y, {}, win);
  184. }
  185. }
  186. // step 9
  187. yield interaction.flushEventLoop(win);
  188. // step 10
  189. // TODO(ato): if the click causes navigation,
  190. // run post-navigation checks
  191. }
  192. function* seleniumClickElement (el, a11y) {
  193. let win = getWindow(el);
  194. let visibilityCheckEl = el;
  195. if (el.localName == "option") {
  196. visibilityCheckEl = element.getContainer(el);
  197. }
  198. if (!element.isVisible(visibilityCheckEl)) {
  199. throw new ElementNotInteractableError();
  200. }
  201. if (!atom.isElementEnabled(el)) {
  202. throw new InvalidElementStateError("Element is not enabled");
  203. }
  204. yield a11y.getAccessible(el, true).then(acc => {
  205. a11y.assertVisible(acc, el, true);
  206. a11y.assertEnabled(acc, el, true);
  207. a11y.assertActionable(acc, el);
  208. });
  209. // chrome elements
  210. if (element.isXULElement(el)) {
  211. if (el.localName == "option") {
  212. interaction.selectOption(el);
  213. } else {
  214. el.click();
  215. }
  216. // content elements
  217. } else {
  218. if (el.localName == "option") {
  219. interaction.selectOption(el);
  220. } else {
  221. let rects = el.getClientRects();
  222. let centre = element.getInViewCentrePoint(rects[0], win);
  223. let opts = {};
  224. event.synthesizeMouseAtPoint(centre.x, centre.y, opts, win);
  225. }
  226. }
  227. };
  228. /**
  229. * Select <option> element in a <select> list.
  230. *
  231. * Because the dropdown list of select elements are implemented using
  232. * native widget technology, our trusted synthesised events are not able
  233. * to reach them. Dropdowns are instead handled mimicking DOM events,
  234. * which for obvious reasons is not ideal, but at the current point in
  235. * time considered to be good enough.
  236. *
  237. * @param {HTMLOptionElement} option
  238. * Option element to select.
  239. *
  240. * @throws TypeError
  241. * If |el| is a XUL element or not an <option> element.
  242. * @throws Error
  243. * If unable to find |el|'s parent <select> element.
  244. */
  245. interaction.selectOption = function (el) {
  246. if (element.isXULElement(el)) {
  247. throw new Error("XUL dropdowns not supported");
  248. }
  249. if (el.localName != "option") {
  250. throw new TypeError("Invalid elements");
  251. }
  252. let win = getWindow(el);
  253. let containerEl = element.getContainer(el);
  254. event.mouseover(containerEl);
  255. event.mousemove(containerEl);
  256. event.mousedown(containerEl);
  257. event.focus(containerEl);
  258. event.input(containerEl);
  259. // toggle selectedness the way holding down control works
  260. el.selected = !el.selected;
  261. event.change(containerEl);
  262. event.mouseup(containerEl);
  263. event.click(containerEl);
  264. };
  265. /**
  266. * Flushes the event loop by requesting an animation frame.
  267. *
  268. * This will wait for the browser to repaint before returning, typically
  269. * flushing any queued events.
  270. *
  271. * If the document is unloaded during this request, the promise is
  272. * rejected.
  273. *
  274. * @param {Window} win
  275. * Associated window.
  276. *
  277. * @return {Promise}
  278. * Promise is accepted once event queue is flushed, or rejected if
  279. * |win| is unloaded before the queue can be flushed.
  280. */
  281. interaction.flushEventLoop = function* (win) {
  282. let unloadEv;
  283. return new Promise((resolve, reject) => {
  284. unloadEv = reject;
  285. win.addEventListener("unload", unloadEv, {once: true});
  286. win.requestAnimationFrame(resolve);
  287. }).then(() => {
  288. win.removeEventListener("unload", unloadEv);
  289. });
  290. };
  291. /**
  292. * Appends |path| to an <input type=file>'s file list.
  293. *
  294. * @param {HTMLInputElement} el
  295. * An <input type=file> element.
  296. * @param {string} path
  297. * Full path to file.
  298. */
  299. interaction.uploadFile = function (el, path) {
  300. let file;
  301. try {
  302. file = File.createFromFileName(path);
  303. } catch (e) {
  304. throw new InvalidArgumentError("File not found: " + path);
  305. }
  306. let fs = Array.prototype.slice.call(el.files);
  307. fs.push(file);
  308. // <input type=file> opens OS widget dialogue
  309. // which means the mousedown/focus/mouseup/click events
  310. // occur before the change event
  311. event.mouseover(el);
  312. event.mousemove(el);
  313. event.mousedown(el);
  314. event.focus(el);
  315. event.mouseup(el);
  316. event.click(el);
  317. el.mozSetFileArray(fs);
  318. event.change(el);
  319. };
  320. /**
  321. * Sets a form element's value.
  322. *
  323. * @param {DOMElement} el
  324. * An form element, e.g. input, textarea, etc.
  325. * @param {string} value
  326. * The value to be set.
  327. *
  328. * @throws TypeError
  329. * If |el| is not an supported form element.
  330. */
  331. interaction.setFormControlValue = function* (el, value) {
  332. if (!COMMON_FORM_CONTROLS.has(el.localName)) {
  333. throw new TypeError("This function is for form elements only");
  334. }
  335. el.value = value;
  336. if (INPUT_TYPES_NO_EVENT.has(el.type)) {
  337. return;
  338. }
  339. event.input(el);
  340. event.change(el);
  341. };
  342. /**
  343. * Send keys to element.
  344. *
  345. * @param {DOMElement|XULElement} el
  346. * Element to send key events to.
  347. * @param {Array.<string>} value
  348. * Sequence of keystrokes to send to the element.
  349. * @param {boolean} ignoreVisibility
  350. * Flag to enable or disable element visibility tests.
  351. * @param {boolean=} strict
  352. * Enforce strict accessibility tests.
  353. */
  354. interaction.sendKeysToElement = function (el, value, ignoreVisibility, strict = false) {
  355. let win = getWindow(el);
  356. let a11y = accessibility.get(strict);
  357. return a11y.getAccessible(el, true).then(acc => {
  358. a11y.assertActionable(acc, el);
  359. event.sendKeysToElement(value, el, {ignoreVisibility: false}, win);
  360. });
  361. };
  362. /**
  363. * Determine the element displayedness of an element.
  364. *
  365. * @param {DOMElement|XULElement} el
  366. * Element to determine displayedness of.
  367. * @param {boolean=} strict
  368. * Enforce strict accessibility tests.
  369. *
  370. * @return {boolean}
  371. * True if element is displayed, false otherwise.
  372. */
  373. interaction.isElementDisplayed = function (el, strict = false) {
  374. let win = getWindow(el);
  375. let displayed = atom.isElementDisplayed(el, win);
  376. let a11y = accessibility.get(strict);
  377. return a11y.getAccessible(el).then(acc => {
  378. a11y.assertVisible(acc, el, displayed);
  379. return displayed;
  380. });
  381. };
  382. /**
  383. * Check if element is enabled.
  384. *
  385. * @param {DOMElement|XULElement} el
  386. * Element to test if is enabled.
  387. *
  388. * @return {boolean}
  389. * True if enabled, false otherwise.
  390. */
  391. interaction.isElementEnabled = function (el, strict = false) {
  392. let enabled = true;
  393. let win = getWindow(el);
  394. if (element.isXULElement(el)) {
  395. // check if XUL element supports disabled attribute
  396. if (DISABLED_ATTRIBUTE_SUPPORTED_XUL.has(el.tagName.toUpperCase())) {
  397. let disabled = atom.getElementAttribute(el, "disabled", win);
  398. if (disabled && disabled === "true") {
  399. enabled = false;
  400. }
  401. }
  402. } else {
  403. enabled = atom.isElementEnabled(el, {frame: win});
  404. }
  405. let a11y = accessibility.get(strict);
  406. return a11y.getAccessible(el).then(acc => {
  407. a11y.assertEnabled(acc, el, enabled);
  408. return enabled;
  409. });
  410. };
  411. /**
  412. * Determines if the referenced element is selected or not.
  413. *
  414. * This operation only makes sense on input elements of the Checkbox-
  415. * and Radio Button states, or option elements.
  416. *
  417. * @param {DOMElement|XULElement} el
  418. * Element to test if is selected.
  419. * @param {boolean=} strict
  420. * Enforce strict accessibility tests.
  421. *
  422. * @return {boolean}
  423. * True if element is selected, false otherwise.
  424. */
  425. interaction.isElementSelected = function (el, strict = false) {
  426. let selected = true;
  427. let win = getWindow(el);
  428. if (element.isXULElement(el)) {
  429. let tagName = el.tagName.toUpperCase();
  430. if (CHECKED_PROPERTY_SUPPORTED_XUL.has(tagName)) {
  431. selected = el.checked;
  432. }
  433. if (SELECTED_PROPERTY_SUPPORTED_XUL.has(tagName)) {
  434. selected = el.selected;
  435. }
  436. } else {
  437. selected = atom.isElementSelected(el, win);
  438. }
  439. let a11y = accessibility.get(strict);
  440. return a11y.getAccessible(el).then(acc => {
  441. a11y.assertSelected(acc, el, selected);
  442. return selected;
  443. });
  444. };
  445. function getWindow(el) {
  446. return el.ownerDocument.defaultView;
  447. }