Bug 1255955 - Add support for interacting with <select> elements; r?automatedtester draft
authorAndreas Tolfsen <ato@mozilla.com>
Fri, 05 Aug 2016 18:08:31 +0100
changeset 400459 ccb03a53177cc9dfc95ae2a18aafb35d5463610b
parent 400458 7abc9b79bc239a96f67434b32bbd2e6d75340870
child 400460 ca6e2bde377fa61ff6cc11ec3c7bcffbdfc40d0b
push id26147
push userbmo:ato@mozilla.com
push dateSat, 13 Aug 2016 19:00:38 +0000
reviewersautomatedtester
bugs1255955
milestone51.0a1
Bug 1255955 - Add support for interacting with <select> elements; r?automatedtester This patch introduces support for clicking on <select> and <select multiple> elements to Marionette. As <select> elements, especially <select multiple>, are operating system level concepts usually implemented with native widget sets, this patch takes the approach of dispatching generated events. MozReview-Commit-ID: 9kwOva43AOL
testing/marionette/harness/marionette/tests/unit/test_select.py
testing/marionette/harness/marionette/tests/unit/unit-tests.ini
testing/marionette/interaction.js
new file mode 100644
--- /dev/null
+++ b/testing/marionette/harness/marionette/tests/unit/test_select.py
@@ -0,0 +1,163 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import urllib
+
+from marionette import MarionetteTestCase
+from marionette_driver.by import By
+
+
+def inline(doc):
+    return "data:text/html;charset=utf-8,%s" % urllib.quote(doc)
+
+
+class SelectTestCase(MarionetteTestCase):
+    def assertSelected(self, option_element):
+        self.assertTrue(option_element.is_selected(), "<option> element not selected")
+        self.assertTrue(self.marionette.execute_script(
+            "return arguments[0].selected", script_args=[option_element], sandbox=None),
+            "<option> selected attribute not updated")
+
+    def assertNotSelected(self, option_element):
+        self.assertFalse(option_element.is_selected(), "<option> is selected")
+        self.assertFalse(self.marionette.execute_script(
+            "return arguments[0].selected", script_args=[option_element], sandbox=None),
+            "<option> selected attribute not updated")
+
+
+class TestSelect(SelectTestCase):
+    def test_single(self):
+        self.marionette.navigate(inline("""
+            <select>
+              <option>first
+              <option>second
+            </select>"""))
+        select = self.marionette.find_element(By.TAG_NAME, "select")
+        options = self.marionette.find_elements(By.TAG_NAME, "option")
+
+        self.assertSelected(options[0])
+        options[1].click()
+        self.assertSelected(options[1])
+
+    def test_deselect(self):
+        self.marionette.navigate(inline("""
+          <select>
+            <option>first
+            <option>second
+            <option>third
+          </select>"""))
+        select = self.marionette.find_element(By.TAG_NAME, "select")
+        options = self.marionette.find_elements(By.TAG_NAME, "option")
+
+        options[0].click()
+        self.assertSelected(options[0])
+        options[1].click()
+        self.assertSelected(options[1])
+        options[2].click()
+        self.assertSelected(options[2])
+        options[0].click()
+        self.assertSelected(options[0])
+
+    def test_out_of_view(self):
+        self.marionette.navigate(inline("""
+          <select>
+            <option>1
+            <option>2
+            <option>3
+            <option>4
+            <option>5
+            <option>6
+            <option>7
+            <option>8
+            <option>9
+            <option>10
+            <option>11
+            <option>12
+            <option>13
+            <option>14
+            <option>15
+            <option>16
+            <option>17
+            <option>18
+            <option>19
+            <option>20
+          </select>"""))
+        select = self.marionette.find_element(By.TAG_NAME, "select")
+        options = self.marionette.find_elements(By.TAG_NAME, "option")
+
+        options[14].click()
+        self.assertSelected(options[14])
+
+
+class TestSelectMultiple(SelectTestCase):
+    def test_single(self):
+        self.marionette.navigate(inline("<select multiple> <option>first </select>"))
+        option = self.marionette.find_element(By.TAG_NAME, "option")
+        option.click()
+        self.assertSelected(option)
+
+    def test_multiple(self):
+        self.marionette.navigate(inline("""
+          <select multiple>
+            <option>first
+            <option>second
+            <option>third
+          </select>"""))
+        select = self.marionette.find_element(By.TAG_NAME, "select")
+        options = select.find_elements(By.TAG_NAME, "option")
+
+        options[1].click()
+        self.assertSelected(options[1])
+
+        options[2].click()
+        self.assertSelected(options[2])
+        self.assertSelected(options[1])
+
+    def test_deselect_selected(self):
+        self.marionette.navigate(inline("<select multiple> <option>first </select>"))
+        option = self.marionette.find_element(By.TAG_NAME, "option")
+        option.click()
+        self.assertSelected(option)
+        option.click()
+        self.assertNotSelected(option)
+
+    def test_deselect_preselected(self):
+        self.marionette.navigate(inline("""
+          <select multiple>
+            <option selected>first
+          </select>"""))
+        option = self.marionette.find_element(By.TAG_NAME, "option")
+        self.assertSelected(option)
+        option.click()
+        self.assertNotSelected(option)
+
+    def test_out_of_view(self):
+        self.marionette.navigate(inline("""
+          <select multiple>
+            <option>1
+            <option>2
+            <option>3
+            <option>4
+            <option>5
+            <option>6
+            <option>7
+            <option>8
+            <option>9
+            <option>10
+            <option>11
+            <option>12
+            <option>13
+            <option>14
+            <option>15
+            <option>16
+            <option>17
+            <option>18
+            <option>19
+            <option>20
+          </select>"""))
+        select = self.marionette.find_element(By.TAG_NAME, "select")
+        options = self.marionette.find_elements(By.TAG_NAME, "option")
+
+        options[-1].click()
+        self.assertSelected(options[-1])
--- a/testing/marionette/harness/marionette/tests/unit/unit-tests.ini
+++ b/testing/marionette/harness/marionette/tests/unit/unit-tests.ini
@@ -125,9 +125,10 @@ skip-if = buildapp == 'b2g' || os == "wi
 [test_using_permissions.py]
 [test_using_prefs.py]
 
 [test_shadow_dom.py]
 
 [test_chrome.py]
 skip-if = buildapp == 'b2g'
 
-[test_addons.py]
\ No newline at end of file
+[test_addons.py]
+[test_select.py]
--- a/testing/marionette/interaction.js
+++ b/testing/marionette/interaction.js
@@ -86,50 +86,140 @@ this.interaction = {};
  * @param {boolean=} specCompat
  *     Use WebDriver specification compatible interactability definition.
  */
 interaction.clickElement = function(el, strict = false, specCompat = false) {
   let a11y = accessibility.get(strict);
   return a11y.getAccessible(el, true).then(acc => {
     let win = getWindow(el);
 
+    let selectEl;
+    let visibilityCheckEl = el;
+    if (el.localName == "option") {
+      selectEl = interaction.getSelectForOptionElement(el);
+      visibilityCheckEl = selectEl;
+    }
+
     let visible = false;
     if (specCompat) {
-      visible = element.isInteractable(el);
+      visible = element.isInteractable(visibilityCheckEl);
       if (!visible) {
         el.scrollIntoView(false);
       }
-      visible = element.isInteractable(el);
+      visible = element.isInteractable(visibilityCheckEl);
     } else {
-      visible = element.isVisible(el);
+      visible = element.isVisible(visibilityCheckEl);
     }
 
     if (!visible) {
       throw new ElementNotVisibleError("Element is not visible");
     }
-    a11y.assertVisible(acc, el, visible);
-    if (!atom.isElementEnabled(el)) {
+    a11y.assertVisible(acc, visibilityCheckEl, visible);
+    if (!atom.isElementEnabled(visibilityCheckEl)) {
       throw new InvalidElementStateError("Element is not enabled");
     }
-    a11y.assertEnabled(acc, el, true);
-    a11y.assertActionable(acc, el);
+    a11y.assertEnabled(acc, visibilityCheckEl, true);
+    a11y.assertActionable(acc, visibilityCheckEl);
 
+    // chrome elements
     if (element.isXULElement(el)) {
-      el.click();
+      if (el.localName == "option") {
+        interaction.selectOption(el);
+      } else {
+        el.click();
+      }
+
+    // content elements
     } else {
-      let rects = el.getClientRects();
-      let coords = {
-        x: rects[0].left + rects[0].width / 2.0,
-        y: rects[0].top + rects[0].height / 2.0,
-      };
-      event.synthesizeMouseAtPoint(coords.x, coords.y, {} /* opts */, win);
+      if (el.localName == "option") {
+        interaction.selectOption(el);
+      } else {
+        let centre = interaction.calculateCentreCoords(el);
+        let opts = {};
+        event.synthesizeMouseAtPoint(centre.x, centre.y, opts, win);
+      }
     }
   });
 };
 
+interaction.calculateCentreCoords = function(el) {
+  let rects = el.getClientRects();
+  return {
+    x: rects[0].left + rects[0].width / 2.0,
+    y: rects[0].top + rects[0].height / 2.0,
+  };
+};
+
+/**
+ * Select <option> element in a <select> list.
+ *
+ * Because the dropdown list of select elements are implemented using
+ * native widget technology, our trusted synthesised events are not able
+ * to reach them.  Dropdowns are instead handled mimicking DOM events,
+ * which for obvious reasons is not ideal, but at the current point in
+ * time considered to be good enough.
+ *
+ * @param {HTMLOptionElement} option
+ *     Option element to select.
+ *
+ * @throws TypeError
+ *     If |el| is a XUL element or not an <option> element.
+ * @throws Error
+ *     If unable to find |el|'s parent <select> element.
+ */
+interaction.selectOption = function(el) {
+  if (element.isXULElement(el)) {
+    throw new Error("XUL dropdowns not supported");
+  }
+  if (el.localName != "option") {
+    throw new TypeError("Invalid elements");
+  }
+
+  let win = getWindow(el);
+  let parent = interaction.getSelectForOptionElement(el);
+
+  event.mouseover(parent);
+  event.mousemove(parent);
+  event.mousedown(parent);
+  event.focus(parent);
+  event.input(parent);
+
+  // toggle selectedness the way holding down control works
+  el.selected = !el.selected;
+
+  event.change(parent);
+  event.mouseup(parent);
+  event.click(parent);
+};
+
+/**
+ * Locate the <select> element that encapsulate an <option> element.
+ *
+ * @param {HTMLOptionElement} optionEl
+ *     Option element.
+ *
+ * @return {HTMLSelectElement}
+ *     Select element wrapping |optionEl|.
+ *
+ * @throws {Error}
+ *     If unable to find the <select> element.
+ */
+interaction.getSelectForOptionElement = function(optionEl) {
+  let parent = optionEl;
+  while (parent.parentNode && parent.localName != "select") {
+    parent = parent.parentNode;
+  }
+
+  if (parent.localName != "select") {
+    throw new Error("Unable to find parent of <option> element");
+  }
+
+  return parent;
+};
+
 /**
  * Send keys to element.
  *
  * @param {DOMElement|XULElement} el
  *     Element to send key events to.
  * @param {Array.<string>} value
  *     Sequence of keystrokes to send to the element.
  * @param {boolean} ignoreVisibility