Bug 1400256 - Add web element abstractions. r?whimboo draft
authorAndreas Tolfsen <ato@sny.no>
Fri, 13 Oct 2017 19:23:45 +0100
changeset 683308 81bc09d097a60a878b4c44103d39010fa51aa998
parent 683249 31af3ee0436093bfd3300e9002f1118df0420309
child 683309 07802c6c0996a3909ed062de7d3e02f53fd54510
push id85331
push userbmo:ato@sny.no
push dateThu, 19 Oct 2017 14:55:23 +0000
reviewerswhimboo
bugs1400256
milestone58.0a1
Bug 1400256 - Add web element abstractions. r?whimboo This patch introduces a series of web element abstraction types for representing web element references. Adds a series of new types for representing web element references in Marionette: ChromeWebElement, ContentWebElement, ContentWebFrame, and ContentWebWindow. The last three are direct representations of web element, web frame, and web window definitions described in the Webdriver specification. The first is a custom Marionette type as we also support retrieving XUL elements from chrome space and must be considered proprietary. Each of the classes extend the WebElement abstract type, which is the primary entry point when unmarshaling JSON input from the client. Based on the characteristics of the JSON Object, one of the different concrete types will be constructed. The purpose of this change is to make marshaling of elements and WindowProxies easier, both when we receive web element reference objects from clients and when transporting them over IPC internally. The WebElement.fromUUID function should be considered a temporary workaround until we have fixed the current Marionette clients to send web element reference JSON Objects as input, instead of plain {id: <uuid>, …} fields. MozReview-Commit-ID: FGcRq5H1Tzp
testing/marionette/element.js
testing/marionette/test_element.js
--- a/testing/marionette/element.js
+++ b/testing/marionette/element.js
@@ -12,17 +12,24 @@ Cu.import("chrome://marionette/content/a
 const {
   InvalidSelectorError,
   NoSuchElementError,
   StaleElementReferenceError,
 } = Cu.import("chrome://marionette/content/error.js", {});
 const {pprint} = Cu.import("chrome://marionette/content/format.js", {});
 const {PollPromise} = Cu.import("chrome://marionette/content/sync.js", {});
 
-this.EXPORTED_SYMBOLS = ["element"];
+this.EXPORTED_SYMBOLS = [
+  "ChromeWebElement",
+  "ContentWebElement",
+  "ContentWebFrame",
+  "ContentWebWindow",
+  "element",
+  "WebElement",
+];
 
 const SVGNS = "http://www.w3.org/2000/svg";
 const XBLNS = "http://www.mozilla.org/xbl";
 const XHTMLNS = "http://www.w3.org/1999/xhtml";
 const XULNS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
 
 /** XUL elements that support checked property. */
 const XUL_CHECKED_ELS = new Set([
@@ -1188,8 +1195,301 @@ element.isBooleanAttribute = function(el
     return true;
   }
 
   if (!boolEls.hasOwnProperty(el.localName)) {
     return false;
   }
   return boolEls[el.localName].includes(attr);
 };
+
+/**
+ * A web element is an abstraction used to identify an element when
+ * it is transported via the protocol, between remote- and local ends.
+ *
+ * In Marionette this abstraction can represent DOM elements,
+ * WindowProxies, and XUL elements.
+ */
+class WebElement {
+  /**
+   * @param {string} uuid
+   *     Identifier that must be unique across all browsing contexts
+   *     for the contract to be upheld.
+   */
+  constructor(uuid) {
+    this.uuid = assert.string(uuid);
+  }
+
+  /**
+   * Performs an equality check between this web element and
+   * <var>other</var>.
+   *
+   * @param {WebElement} other
+   *     Web element to compare with this.
+   *
+   * @return {boolean}
+   *     True if this and <var>other</var> are the same.  False
+   *     otherwise.
+   */
+  is(other) {
+    return other instanceof WebElement && this.uuid === other.uuid;
+  }
+
+  toString() {
+    return `[object ${this.constructor.name} uuid=${this.uuid}]`;
+  }
+
+  /**
+   * Returns a new {@link WebElement} reference for a DOM element,
+   * <code>WindowProxy</code>, or XUL element.
+   *
+   * @param {(Element|WindowProxy|XULElement)} node
+   *     Node to construct a web element reference for.
+   *
+   * @return {(ContentWebElement|ChromeWebElement)}
+   *     Web element reference for <var>el</var>.
+   *
+   * @throws {TypeError}
+   *     If <var>node</var> is neither a <code>WindowProxy</code>,
+   *     DOM element, or a XUL element.
+   */
+  static from(node) {
+    const uuid = WebElement.generateUUID();
+
+    if (element.isDOMElement(node) || element.isSVGElement(node)) {
+      return new ContentWebElement(uuid);
+    } else if (element.isDOMWindow(node)) {
+      if (node.parent === node) {
+        return new ContentWebWindow(uuid);
+      }
+      return new ContentWebFrame(uuid);
+    } else if (element.isXULElement(node)) {
+      return new ChromeWebElement(uuid);
+    }
+
+    throw new TypeError("Expected DOM window/element " +
+        pprint`or XUL element, got: ${node}`);
+  }
+
+  /**
+   * Unmarshals a JSON Object to one of {@link ContentWebElement},
+   * {@link ContentWebWindow}, {@link ContentWebFrame}, or
+   * {@link ChromeWebElement}.
+   *
+   * @param {Object.<string, string>} json
+   *     Web element reference, which is supposed to be a JSON Object
+   *     where the key is one of the {@link WebElement} concrete
+   *     classes' UUID identifiers.
+   *
+   * @return {WebElement}
+   *     Representation of the web element.
+   *
+   * @throws {TypeError}
+   *     If <var>json</var> is not a web element reference.
+   */
+  static fromJSON(json) {
+    let keys = [];
+    try {
+      keys = Object.keys(json);
+    } catch (e) {
+      throw new TypeError(`Expected JSON Object: ${e}`);
+    }
+
+    for (let key of keys) {
+      switch (key) {
+        case ContentWebElement.Identifier:
+        case ContentWebElement.LegacyIdentifier:
+          return ContentWebElement.fromJSON(json);
+
+        case ContentWebWindow.Identifier:
+          return ContentWebWindow.fromJSON(json);
+
+        case ContentWebFrame.Identifier:
+          return ContentWebFrame.fromJSON(json);
+
+        case ChromeWebElement.Identifier:
+          return ChromeWebElement.fromJSON(json);
+      }
+    }
+
+    throw new TypeError(
+        pprint`Expected web element reference, got: ${json}`);
+  }
+
+  /**
+   * Constructs a {@link ContentWebElement} or {@link ChromeWebElement}
+   * from a a string <var>uuid</var>.
+   *
+   * This whole function is a workaround for the fact that clients
+   * to Marionette occasionally pass <code>{id: <uuid>}</code> JSON
+   * Objects instead of web element representations.  For that reason
+   * we need the <var>context</var> argument to determine what kind of
+   * {@link WebElement} to return.
+   *
+   * @param {string} uuid
+   *     UUID to be associated with the web element.
+   * @param {Context} context
+   *     Context, which is used to determine if the returned type
+   *     should be a content web element or a chrome web element.
+   *
+   * @return {WebElement}
+   *     One of {@link ContentWebElement} or {@link ChromeWebElement},
+   *     based on <var>context</var>.
+   *
+   * @throws {InvalidArgumentError}
+   *     If <var>uuid</var> is not a string.
+   * @throws {TypeError}
+   *     If <var>context</var> is an invalid context.
+   */
+  static fromUUID(uuid, context) {
+    assert.string(uuid);
+
+    switch (context) {
+      case "chrome":
+        return new ChromeWebElement(uuid);
+
+      case "content":
+        return new ContentWebElement(uuid);
+
+      default:
+        throw new TypeError("Unknown context: " + context);
+    }
+  }
+
+  /**
+   * Checks if <var>ref<var> is a {@link WebElement} reference,
+   * i.e. if it has {@link ContentWebElement.Identifier},
+   * {@link ContentWebElement.LegacyIdentifier}, or
+   * {@link ChromeWebElement.Identifier} as properties.
+   *
+   * @param {Object.<string, string>} obj
+   *     Object that represents a reference to a {@link WebElement}.
+   * @return {boolean}
+   *     True if <var>obj</var> is a {@link WebElement}, false otherwise.
+   */
+  static isReference(obj) {
+    if (Object.prototype.toString.call(obj) != "[object Object]") {
+      return false;
+    }
+
+    if ((ContentWebElement.Identifier in obj) ||
+        (ContentWebElement.LegacyIdentifier in obj) ||
+        (ContentWebWindow.Identifier in obj) ||
+        (ContentWebFrame.Identifier in obj) ||
+        (ChromeWebElement.Identifier in obj)) {
+      return true;
+    }
+    return false;
+  }
+
+  /**
+   * Generates a unique identifier.
+   *
+   * @return {string}
+   *     UUID.
+   */
+  static generateUUID() {
+    let uuid = uuidGen.generateUUID().toString();
+    return uuid.substring(1, uuid.length - 1);
+  }
+}
+this.WebElement = WebElement;
+
+/**
+ * DOM elements are represented as web elements when they are
+ * transported over the wire protocol.
+ */
+class ContentWebElement extends WebElement {
+  toJSON() {
+    return {
+      [ContentWebElement.Identifier]: this.uuid,
+      [ContentWebElement.LegacyIdentifier]: this.uuid,
+    };
+  }
+
+  static fromJSON(json) {
+    const {Identifier, LegacyIdentifier} = ContentWebElement;
+
+    if (!(Identifier in json) && !(LegacyIdentifier in json)) {
+      throw new TypeError(
+          pprint`Expected web element reference, got: ${json}`);
+    }
+
+    let uuid = json[Identifier] || json[LegacyIdentifier];
+    return new ContentWebElement(uuid);
+  }
+}
+ContentWebElement.Identifier = "element-6066-11e4-a52e-4f735466cecf";
+ContentWebElement.LegacyIdentifier = "ELEMENT";
+this.ContentWebElement = ContentWebElement;
+
+/**
+ * Top-level browsing contexts, such as <code>WindowProxy</code>
+ * whose <code>opener</code> is null, are represented as web windows
+ * over the wire protocol.
+ */
+class ContentWebWindow extends WebElement {
+  toJSON() {
+    return {
+      [ContentWebWindow.Identifier]: this.uuid,
+      [ContentWebElement.LegacyIdentifier]: this.uuid,
+    };
+  }
+
+  static fromJSON(json) {
+    if (!(ContentWebWindow.Identifier in json)) {
+      throw new TypeError(
+          pprint`Expected web window reference, got: ${json}`);
+    }
+    let uuid = json[ContentWebWindow.Identifier];
+    return new ContentWebWindow(uuid);
+  }
+}
+ContentWebWindow.Identifier = "window-fcc6-11e5-b4f8-330a88ab9d7f";
+this.ContentWebWindow = ContentWebWindow;
+
+/**
+ * Nested browsing contexts, such as the <code>WindowProxy</code>
+ * associated with <tt>&lt;frame&gt;</tt> and <tt>&lt;iframe&gt;</tt>,
+ * are represented as web frames over the wire protocol.
+ */
+class ContentWebFrame extends WebElement {
+  toJSON() {
+    return {
+      [ContentWebFrame.Identifier]: this.uuid,
+      [ContentWebElement.LegacyIdentifier]: this.uuid,
+    };
+  }
+
+  static fromJSON(json) {
+    if (!(ContentWebFrame.Identifier in json)) {
+      throw new TypeError(pprint`Expected web frame reference, got: ${json}`);
+    }
+    let uuid = json[ContentWebFrame.Identifier];
+    return new ContentWebFrame(uuid);
+  }
+}
+ContentWebFrame.Identifier = "frame-075b-4da1-b6ba-e579c2d3230a";
+this.ContentWebFrame = ContentWebFrame;
+
+/**
+ * XUL elements in chrome space are represented as chrome web elements
+ * over the wire protocol.
+ */
+class ChromeWebElement extends WebElement {
+  toJSON() {
+    return {
+      [ChromeWebElement.Identifier]: this.uuid,
+      [ContentWebElement.LegacyIdentifier]: this.uuid,
+    };
+  }
+
+  static fromJSON(json) {
+    if (!(ChromeWebElement.Identifier in json)) {
+      throw new TypeError("Expected chrome element reference " +
+          pprint`for XUL/XBL element, got: ${json}`);
+    }
+    let uuid = json[ChromeWebElement.Identifier];
+    return new ChromeWebElement(uuid);
+  }
+}
+ChromeWebElement.Identifier = "chromeelement-9fc5-4b51-a3c8-01716eedeb04";
+this.ChromeWebElement = ChromeWebElement;
--- a/testing/marionette/test_element.js
+++ b/testing/marionette/test_element.js
@@ -1,15 +1,22 @@
 /* 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/. */
 
 const {utils: Cu} = Components;
 
-Cu.import("chrome://marionette/content/element.js");
+const {
+  ChromeWebElement,
+  ContentWebElement,
+  ContentWebFrame,
+  ContentWebWindow,
+  element,
+  WebElement,
+} = Cu.import("chrome://marionette/content/element.js", {});
 
 const SVGNS = "http://www.w3.org/2000/svg";
 const XBLNS = "http://www.mozilla.org/xbl";
 const XHTMLNS = "http://www.w3.org/1999/xhtml";
 const XULNS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
 
 class Element {
   constructor(tagName, attrs = {}) {
@@ -212,8 +219,242 @@ add_test(function test_isWebElementRefer
   strictEqual(element.isWebElementReference({[element.LegacyKey]: "some_id"}), true);
   strictEqual(element.isWebElementReference(
       {[element.LegacyKey]: "some_id", [element.Key]: "2"}), true);
   strictEqual(element.isWebElementReference({}), false);
   strictEqual(element.isWebElementReference({"key": "blah"}), false);
 
   run_next_test();
 });
+
+add_test(function test_WebElement_ctor() {
+  let el = new WebElement("foo");
+  equal(el.uuid, "foo");
+
+  for (let t of [42, true, [], {}, null, undefined]) {
+    Assert.throws(() => new WebElement(t));
+  }
+
+  run_next_test();
+});
+
+add_test(function test_WebElemenet_is() {
+  let a = new WebElement("a");
+  let b = new WebElement("b");
+
+  ok(a.is(a));
+  ok(b.is(b));
+  ok(!a.is(b));
+  ok(!b.is(a));
+
+  ok(!a.is({}));
+
+  run_next_test();
+});
+
+add_test(function test_WebElement_from() {
+  ok(WebElement.from(domEl) instanceof ContentWebElement);
+  ok(WebElement.from(domWin) instanceof ContentWebWindow);
+  ok(WebElement.from(domFrame) instanceof ContentWebFrame);
+  ok(WebElement.from(xulEl) instanceof ChromeWebElement);
+
+  Assert.throws(() => WebElement.from({}), /Expected DOM window\/element/);
+
+  run_next_test();
+});
+
+add_test(function test_WebElement_fromJSON_ContentWebElement() {
+  const {Identifier, LegacyIdentifier} = ContentWebElement;
+
+  let refNew = {[Identifier]: "foo"};
+  let webElNew = WebElement.fromJSON(refNew);
+  ok(webElNew instanceof ContentWebElement);
+  equal(webElNew.uuid, "foo");
+
+  let refOld = {[LegacyIdentifier]: "foo"};
+  let webElOld = WebElement.fromJSON(refOld);
+  ok(webElOld instanceof ContentWebElement);
+  equal(webElOld.uuid, "foo");
+
+  ok(webElNew.is(webElOld));
+  ok(webElOld.is(webElNew));
+
+  let refBoth = {
+    [Identifier]: "foo",
+    [LegacyIdentifier]: "foo",
+  };
+  let webElBoth = WebElement.fromJSON(refBoth);
+  ok(webElBoth instanceof ContentWebElement);
+  equal(webElBoth.uuid, "foo");
+
+  ok(webElBoth.is(webElNew));
+  ok(webElBoth.is(webElOld));
+  ok(webElNew.is(webElBoth));
+  ok(webElOld.is(webElBoth));
+
+  let identifierPrecedence = {
+    [Identifier]: "identifier-uuid",
+    [LegacyIdentifier]: "legacyidentifier-uuid",
+  };
+  let precedenceEl = WebElement.fromJSON(identifierPrecedence);
+  ok(precedenceEl instanceof ContentWebElement);
+  equal(precedenceEl.uuid, "identifier-uuid");
+
+  run_next_test();
+});
+
+add_test(function test_WebElement_fromJSON_ContentWebWindow() {
+  let ref = {[ContentWebWindow.Identifier]: "foo"};
+  let win = WebElement.fromJSON(ref);
+  ok(win instanceof ContentWebWindow);
+  equal(win.uuid, "foo");
+
+  run_next_test();
+});
+
+add_test(function test_WebElement_fromJSON_ContentWebFrame() {
+  let ref = {[ContentWebFrame.Identifier]: "foo"};
+  let frame = WebElement.fromJSON(ref);
+  ok(frame instanceof ContentWebFrame);
+  equal(frame.uuid, "foo");
+
+  run_next_test();
+});
+
+add_test(function test_WebElement_fromJSON_ChromeWebElement() {
+  let ref = {[ChromeWebElement.Identifier]: "foo"};
+  let el = WebElement.fromJSON(ref);
+  ok(el instanceof ChromeWebElement);
+  equal(el.uuid, "foo");
+
+  run_next_test();
+});
+
+add_test(function test_WebElement_fromJSON_malformed() {
+  Assert.throws(() => WebElement.fromJSON({}), /Expected web element reference/);
+  Assert.throws(() => WebElement.fromJSON(null), /Expected JSON Object/);
+  run_next_test();
+});
+
+add_test(function test_WebElement_fromUUID() {
+  let xulWebEl = WebElement.fromUUID("foo", "chrome");
+  ok(xulWebEl instanceof ChromeWebElement);
+  equal(xulWebEl.uuid, "foo");
+
+  let domWebEl = WebElement.fromUUID("bar", "content");
+  ok(domWebEl instanceof ContentWebElement);
+  equal(domWebEl.uuid, "bar");
+
+  Assert.throws(() => WebElement.fromUUID("baz", "bah"), /Unknown context/);
+
+  run_next_test();
+});
+
+add_test(function test_WebElement_isReference() {
+  for (let t of [42, true, "foo", [], {}]) {
+    ok(!WebElement.isReference(t));
+  }
+
+  ok(WebElement.isReference({[ContentWebElement.Identifier]: "foo"}));
+  ok(WebElement.isReference({[ContentWebElement.LegacyIdentifier]: "foo"}));
+  ok(WebElement.isReference({[ContentWebWindow.Identifier]: "foo"}));
+  ok(WebElement.isReference({[ContentWebFrame.Identifier]: "foo"}));
+  ok(WebElement.isReference({[ChromeWebElement.Identifier]: "foo"}));
+
+  run_next_test();
+});
+
+add_test(function test_WebElement_generateUUID() {
+  equal(typeof WebElement.generateUUID(), "string");
+  run_next_test();
+});
+
+add_test(function test_ContentWebElement_toJSON() {
+  const {Identifier, LegacyIdentifier} = ContentWebElement;
+
+  let el = new ContentWebElement("foo");
+  let json = el.toJSON();
+
+  ok(Identifier in json);
+  ok(LegacyIdentifier in json);
+  equal(json[Identifier], "foo");
+  equal(json[LegacyIdentifier], "foo");
+
+  run_next_test();
+});
+
+add_test(function test_ContentWebElement_fromJSON() {
+  const {Identifier, LegacyIdentifier} = ContentWebElement;
+
+  let newEl = ContentWebElement.fromJSON({[Identifier]: "foo"});
+  ok(newEl instanceof ContentWebElement);
+  equal(newEl.uuid, "foo");
+
+  let oldEl = ContentWebElement.fromJSON({[LegacyIdentifier]: "foo"});
+  ok(oldEl instanceof ContentWebElement);
+  equal(oldEl.uuid, "foo");
+
+  let bothRef = {
+    [Identifier]: "identifier-uuid",
+    [LegacyIdentifier]: "legacyidentifier-foo",
+  };
+  let bothEl = ContentWebElement.fromJSON(bothRef);
+  ok(bothEl instanceof ContentWebElement);
+  equal(bothEl.uuid, "identifier-uuid");
+
+  Assert.throws(() => ContentWebElement.fromJSON({}), /Expected web element reference/);
+
+  run_next_test();
+});
+
+add_test(function test_ContentWebWindow_toJSON() {
+  let win = new ContentWebWindow("foo");
+  let json = win.toJSON();
+  ok(ContentWebWindow.Identifier in json);
+  equal(json[ContentWebWindow.Identifier], "foo");
+
+  run_next_test();
+});
+
+add_test(function test_ContentWebWindow_fromJSON() {
+  let ref = {[ContentWebWindow.Identifier]: "foo"};
+  let win = ContentWebWindow.fromJSON(ref);
+  ok(win instanceof ContentWebWindow);
+  equal(win.uuid, "foo");
+
+  run_next_test();
+});
+
+add_test(function test_ContentWebFrame_toJSON() {
+  let frame = new ContentWebFrame("foo");
+  let json = frame.toJSON();
+  ok(ContentWebFrame.Identifier in json);
+  equal(json[ContentWebFrame.Identifier], "foo");
+
+  run_next_test();
+});
+
+add_test(function test_ContentWebFrame_fromJSON() {
+  let ref = {[ContentWebFrame.Identifier]: "foo"};
+  let win = ContentWebFrame.fromJSON(ref);
+  ok(win instanceof ContentWebFrame);
+  equal(win.uuid, "foo");
+
+  run_next_test();
+});
+
+add_test(function test_ChromeWebElement_toJSON() {
+  let el = new ChromeWebElement("foo");
+  let json = el.toJSON();
+  ok(ChromeWebElement.Identifier in json);
+  equal(json[ChromeWebElement.Identifier], "foo");
+
+  run_next_test();
+});
+
+add_test(function test_ChromeWebElement_fromJSON() {
+  let ref = {[ChromeWebElement.Identifier]: "foo"};
+  let win = ChromeWebElement.fromJSON(ref);
+  ok(win instanceof ChromeWebElement);
+  equal(win.uuid, "foo");
+
+  run_next_test();
+});