Bug 1284232 - Marshal all HTML and JS collections; r?automatedtester draft
authorAndreas Tolfsen <ato@mozilla.com>
Mon, 04 Jul 2016 17:34:06 +0100
changeset 385458 8a78ce00563ec29ac4ab465a17d7400830096848
parent 385258 23dc78b7b57e9f91798ea44c242a04e112c37db0
child 385459 5b70fd1ea5a19a851b654f59a27e0a70dcb01ca0
child 385462 78a68e53c82d9e9d3477125a014c9ef888d43ca3
push id22508
push userbmo:ato@mozilla.com
push dateFri, 08 Jul 2016 13:11:39 +0000
reviewersautomatedtester
bugs1284232
milestone50.0a1
Bug 1284232 - Marshal all HTML and JS collections; r?automatedtester This patch adds marshaling of HTMLFormControlsCollection, HTMLAllCollection, and HTMLOptionsCollection element collections to Marionette. It will allow us you to return from HTMLSelectElement.options, document.forms[0].elements, and document.all. This is in addition to the already supported document.querySelector (NodeList), document.getElementsBy* (HTMLCollection), and Array.from(ELEMENT...) collections. MozReview-Commit-ID: 71a65lZRn4S
testing/marionette/element.js
testing/marionette/harness/marionette/tests/unit/test_execute_script.py
testing/marionette/harness/marionette/tests/unit/test_file_upload.py
--- a/testing/marionette/element.js
+++ b/testing/marionette/element.js
@@ -282,19 +282,20 @@ function find_(container, strategy, sele
   let res;
   try {
     res = searchFn(strategy, selector, rootNode, startNode);
   } catch (e) {
     throw new InvalidSelectorError(
         `Given ${strategy} expression "${selector}" is invalid: ${e}`);
   }
 
-  if (element.isElementCollection(res)) {
-    return res;
-  } else if (res) {
+  if (res) {
+    if (opts.all) {
+      return res;
+    }
     return [res];
   }
   return [];
 }
 
 /**
  * Find a single element by XPath expression.
  *
@@ -572,24 +573,32 @@ function implicitlyWaitFor(func, timeout
     let elementSearch = function() {
       let res;
       try {
         res = func();
       } catch (e) {
         reject(e);
       }
 
-      // empty arrays evaluate to true in JS,
-      // so we must first ascertan if the result is a collection
-      //
-      // we also return immediately if timeout is 0,
-      // allowing |func| to be evaluated at least once
-      let col = element.isElementCollection(res);
-      if (((col && res.length > 0 ) || (!col && !!res)) ||
-          (startTime == endTime || new Date().getTime() >= endTime)) {
+      if (
+        // collections that might contain web elements
+        // should be checked until they are not empty
+        (element.isCollection(res) && res.length > 0)
+
+        // !![] (ensuring boolean type on empty array) always returns true
+        // and we can only use it on non-collections
+        || (!element.isCollection(res) && !!res)
+
+        // return immediately if timeout is 0,
+        // allowing |func| to be evaluted at least once
+        || startTime == endTime
+
+        // return if timeout has elapsed
+        || new Date().getTime() >= endTime
+      ) {
         resolve(res);
       }
     };
 
     // the repeating slack timer waits |interval|
     // before invoking |elementSearch|
     elementSearch();
 
@@ -600,29 +609,32 @@ function implicitlyWaitFor(func, timeout
     timer.cancel();
     return res;
   }, err => {
     timer.cancel();
     throw err;
   });
 }
 
-element.isElementCollection = function(seq) {
-  if (seq === null) {
-    return false;
-  }
+/** Determines if |obj| is an HTML or JS collection. */
+element.isCollection = function(seq) {
+  switch (Object.prototype.toString.call(seq)) {
+    case "[object Arguments]":
+    case "[object Array]":
+    case "[object FileList]":
+    case "[object HTMLAllCollection]":
+    case "[object HTMLCollection]":
+    case "[object HTMLFormControlsCollection]":
+    case "[object HTMLOptionsCollection]":
+    case "[object NodeList]":
+      return true;
 
-  const arrayLike = {
-    "[object Array]": 0,
-    "[object HTMLCollection]": 1,
-    "[object NodeList]": 2,
-  };
-
-  let typ = Object.prototype.toString.call(seq);
-  return typ in arrayLike;
+    default:
+      return false;
+  }
 };
 
 element.makeWebElement = function(uuid) {
   return {
     [element.Key]: uuid,
     [element.LegacyKey]: uuid,
   };
 };
@@ -701,53 +713,50 @@ element.fromJson = function(
  * @param {element.Store} seenEls
  *     Element store to use for lookup of web element references.
  *
  * @return {?}
  *     Same object as provided by |obj| with the elements replaced by
  *     web elements.
  */
 element.toJson = function(obj, seenEls) {
-  switch (typeof obj) {
-    case "undefined":
-      return null;
+  let t = Object.prototype.toString.call(obj);
 
-    case "boolean":
-    case "number":
-    case "string":
-      return obj;
+  // null
+  if (t == "[object Undefined]" || t == "[object Null]") {
+    return null;
+  }
 
-    case "object":
-      if (obj === null) {
-        return obj;
-      }
+  // literals
+  else if (t == "[object Boolean]" || t == "[object Number]" || t == "[object String]") {
+    return obj;
+  }
 
-      // NodeList, HTMLCollection
-      else if (element.isElementCollection(obj)) {
-        return [...obj].map(el => element.toJson(el, seenEls));
-      }
+  // Array, NodeList, HTMLCollection, et al.
+  else if (element.isCollection(obj)) {
+    return [...obj].map(el => element.toJson(el, seenEls));
+  }
 
-      // DOM element
-      else if (obj.nodeType == 1) {
-        let uuid = seenEls.add(obj);
-        return {[element.Key]: uuid, [element.LegacyKey]: uuid};
-      }
+  // HTMLElement
+  else if ("nodeType" in obj && obj.nodeType == 1) {
+    let uuid = seenEls.add(obj);
+    return {[element.Key]: uuid, [element.LegacyKey]: uuid};
+  }
 
-      // arbitrary objects
-      else {
-        let rv = {};
-        for (let prop in obj) {
-          try {
-            rv[prop] = element.toJson(obj[prop], seenEls);
-          } catch (e if (e.result == Cr.NS_ERROR_NOT_IMPLEMENTED)) {
-            logger.debug(`Skipping ${prop}: ${e.message}`);
-          }
-        }
-        return rv;
+  // arbitrary objects + files
+  else {
+    let rv = {};
+    for (let prop in obj) {
+      try {
+        rv[prop] = element.toJson(obj[prop], seenEls);
+      } catch (e if (e.result == Cr.NS_ERROR_NOT_IMPLEMENTED)) {
+        logger.debug(`Skipping ${prop}: ${e.message}`);
       }
+    }
+    return rv;
   }
 };
 
 /**
  * Check if the element is detached from the current frame as well as
  * the optional shadow root (when inside a Shadow DOM context).
  *
  * @param {nsIDOMElement} el
--- a/testing/marionette/harness/marionette/tests/unit/test_execute_script.py
+++ b/testing/marionette/harness/marionette/tests/unit/test_execute_script.py
@@ -1,16 +1,17 @@
 # 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
 import os
 
 from marionette_driver import By, errors
+from marionette_driver.marionette import HTMLElement
 from marionette import MarionetteTestCase
 
 
 def inline(doc):
     return "data:text/html;charset=utf-8,%s" % urllib.quote(doc)
 
 
 elements = inline("<p>foo</p> <p>bar</p>")
@@ -283,8 +284,57 @@ class TestExecuteChrome(TestExecuteConte
     def test_return_web_element(self):
         pass
 
     def test_return_web_element_array(self):
         pass
 
     def test_return_web_element_nodelist(self):
         pass
+
+
+class TestElementCollections(MarionetteTestCase):
+    def assertSequenceIsInstance(self, seq, typ):
+        for item in seq:
+            self.assertIsInstance(item, typ)
+
+    def test_array(self):
+        self.marionette.navigate(inline("<p>foo <p>bar"))
+        els = self.marionette.execute_script("return Array.from(document.querySelectorAll('p'))")
+        self.assertIsInstance(els, list)
+        self.assertEqual(2, len(els))
+        self.assertSequenceIsInstance(els, HTMLElement)
+
+    def test_html_all_collection(self):
+        self.marionette.navigate(inline("<p>foo <p>bar"))
+        els = self.marionette.execute_script("return document.all")
+        self.assertIsInstance(els, list)
+        # <html>, <head>, <body>, <p>, <p>
+        self.assertEqual(5, len(els))
+        self.assertSequenceIsInstance(els, HTMLElement)
+
+    def test_html_collection(self):
+        self.marionette.navigate(inline("<p>foo <p>bar"))
+        els = self.marionette.execute_script("return document.getElementsByTagName('p')")
+        self.assertIsInstance(els, list)
+        self.assertEqual(2, len(els))
+        self.assertSequenceIsInstance(els, HTMLElement)
+
+    def test_html_form_controls_collection(self):
+        self.marionette.navigate(inline("<form><input><input></form>"))
+        els = self.marionette.execute_script("return document.forms[0].elements")
+        self.assertIsInstance(els, list)
+        self.assertEqual(2, len(els))
+        self.assertSequenceIsInstance(els, HTMLElement)
+
+    def test_html_options_collection(self):
+        self.marionette.navigate(inline("<select><option><option></select>"))
+        els = self.marionette.execute_script("return document.querySelector('select').options")
+        self.assertIsInstance(els, list)
+        self.assertEqual(2, len(els))
+        self.assertSequenceIsInstance(els, HTMLElement)
+
+    def test_node_list(self):
+        self.marionette.navigate(inline("<p>foo <p>bar"))
+        els = self.marionette.execute_script("return document.querySelectorAll('p')")
+        self.assertIsInstance(els, list)
+        self.assertEqual(2, len(els))
+        self.assertSequenceIsInstance(els, HTMLElement)
--- a/testing/marionette/harness/marionette/tests/unit/test_file_upload.py
+++ b/testing/marionette/harness/marionette/tests/unit/test_file_upload.py
@@ -17,57 +17,56 @@ multiple = "data:text/html,%s" % urllib.
 upload = lambda url: "data:text/html,%s" % urllib.quote("""
     <form action='%s' method=post enctype='multipart/form-data'>
      <input type=file>
      <input type=submit>
     </form>""" % url)
 
 
 class TestFileUpload(MarionetteTestCase):
-
     def test_sets_one_file(self):
         self.marionette.navigate(single)
         input = self.input
 
         exp = None
         with tempfile() as f:
             input.send_keys(f.name)
             exp = [f.name]
 
-        files = self.get_files(input)
+        files = self.get_file_names(input)
         self.assertEqual(len(files), 1)
-        self.assertFilesEqual(files, exp)
+        self.assertFileNamesEqual(files, exp)
 
     def test_sets_multiple_files(self):
         self.marionette.navigate(multiple)
         input = self.input
 
         exp = None
         with contextlib.nested(tempfile(), tempfile()) as (a, b):
             input.send_keys(a.name)
             input.send_keys(b.name)
             exp = [a.name, b.name]
 
-        files = self.get_files(input)
+        files = self.get_file_names(input)
         self.assertEqual(len(files), 2)
-        self.assertFilesEqual(files, exp)
+        self.assertFileNamesEqual(files, exp)
 
     def test_sets_multiple_indentical_files(self):
         self.marionette.navigate(multiple)
         input = self.input
 
         exp = []
         with tempfile() as f:
             input.send_keys(f.name)
             input.send_keys(f.name)
             exp = f.name
 
-        files = self.get_files(input)
+        files = self.get_file_names(input)
         self.assertEqual(len(files), 2)
-        self.assertFilesEqual(files, exp)
+        self.assertFileNamesEqual(files, exp)
 
     def test_clear_file(self):
         self.marionette.navigate(single)
         input = self.input
 
         with tempfile() as f:
             input.send_keys(f.name)
 
@@ -117,21 +116,21 @@ class TestFileUpload(MarionetteTestCase)
     def submit(self):
         return self.find_inputs()[1]
 
     @property
     def body(self):
         return Wait(self.marionette).until(
             expected.element_present(By.TAG_NAME, "body"))
 
+    def get_file_names(self, el):
+        fl = self.get_files(el)
+        return [f["name"] for f in fl]
+
     def get_files(self, el):
-        # This is horribly complex because (1) Marionette doesn't serialise arrays properly,
-        # and (2) accessing File.name in the content JS throws a permissions
-        # error.
-        fl = self.marionette.execute_script(
+        return self.marionette.execute_script(
             "return arguments[0].files", script_args=[el])
-        return [f["name"] for f in [v for k, v in fl.iteritems() if k.isdigit()]]
 
-    def assertFilesEqual(self, act, exp):
+    def assertFileNamesEqual(self, act, exp):
         # File array returned from browser doesn't contain full path names,
         # this cuts off the path of the expected files.
         filenames = [f.rsplit("/", 0)[-1] for f in act]
         self.assertListEqual(filenames, act)