Bug 1473996 - Expose getPropertyValue in devtools server to fully evaluate an object property. r=nchevobbe draft
authorLogan F Smyth <loganfsmyth@gmail.com>
Thu, 12 Jul 2018 11:08:38 -0700
changeset 817510 0f35c1ded9923b8a7b35a8af924a3b1cbf53fab9
parent 817076 3aca103e49150145dbff910be15e7886b7c4495a
child 817511 3811894117519577c048a28b5ca5e009ea707391
push id116081
push userbmo:loganfsmyth@gmail.com
push dateThu, 12 Jul 2018 18:10:54 +0000
reviewersnchevobbe
bugs1473996
milestone63.0a1
Bug 1473996 - Expose getPropertyValue in devtools server to fully evaluate an object property. r=nchevobbe MozReview-Commit-ID: IYIplkrqQ76
devtools/server/actors/object.js
devtools/server/tests/unit/test_objectgrips-property-value.js
devtools/server/tests/unit/xpcshell.ini
devtools/shared/client/object-client.js
devtools/shared/specs/object.js
--- a/devtools/server/actors/object.js
+++ b/devtools/server/actors/object.js
@@ -23,16 +23,18 @@ const {
   getArrayLength,
   getPromiseState,
   getStorageLength,
   isArray,
   isStorage,
   isTypedArray,
 } = require("devtools/server/actors/object/utils");
 
+const propertyValueGettersMap = new WeakMap();
+
 const proto = {
   /**
    * Creates an actor for the specified object.
    *
    * @param obj Debugger.Object
    *        The debuggee object.
    * @param Object
    *        A collection of abstract methods that are implemented by the caller.
@@ -486,16 +488,85 @@ const proto = {
     if (!name) {
       return this.throwError("missingParameter", "no property name was specified");
     }
 
     return { descriptor: this._propertyDescriptor(name) };
   },
 
   /**
+   * Handle a protocol request to provide the value of the object's
+   * specified property.
+   *
+   * Note: Since this will evaluate getters, it can trigger execution of
+   * content code and may cause side effects. This endpoint should only be used
+   * when you are confident that the side-effects will be safe, or the user
+   * is expecting the effects.
+   *
+   * @param {string} name
+   *        The property we want the value of.
+   */
+  propertyValue: function(name) {
+    if (!name) {
+      return this.throwError("missingParameter", "no property name was specified");
+    }
+
+    const value = this._getPropertyGetter()(this.obj, name);
+
+    return { value: this._buildCompletion(value) };
+  },
+
+  /**
+   * Rather than re-implement the logic for looking up the property of an
+   * object, this utility allows for easily generating a content function
+   * that can perform that lookup.
+   */
+  _getPropertyGetter() {
+    const { global }  = this.obj;
+    let getter = propertyValueGettersMap.get(global);
+    if (getter) {
+      return getter;
+    }
+
+    const debugeeGetter = global.executeInGlobal("((obj, key) => obj[key]);").return;
+    getter = (obj, key) => {
+      // eslint-disable-next-line no-useless-call
+      return debugeeGetter.call(undefined, obj, key);
+    };
+    propertyValueGettersMap.set(global, getter);
+
+    return getter;
+  },
+
+  /**
+   * Converts a Debugger API completion value record into an eqivalent
+   * object grip for use by the API.
+   *
+   * See https://developer.mozilla.org/en-US/docs/Tools/Debugger-API/Conventions#completion-values
+   * for more specifics on the expected behavior.
+   */
+  _buildCompletion(value) {
+    let completionGrip = null;
+
+    // .apply result will be falsy if the script being executed is terminated
+    // via the "slow script" dialog.
+    if (value) {
+      completionGrip = {};
+      if ("return" in value) {
+        completionGrip.return = this.hooks.createValueGrip(value.return);
+      }
+      if ("throw" in value) {
+        completionGrip.throw = this.hooks.createValueGrip(value.throw);
+      }
+    }
+
+    return completionGrip;
+  },
+
+  /**
    * Handle a protocol request to provide the display string for the object.
    */
   displayString: function() {
     const string = stringify(this.obj);
     return { displayString: this.hooks.createValueGrip(string) };
   },
 
   /**
new file mode 100644
--- /dev/null
+++ b/devtools/server/tests/unit/test_objectgrips-property-value.js
@@ -0,0 +1,171 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint-disable no-shadow, max-nested-callbacks */
+
+"use strict";
+
+async function run_test() {
+  try {
+    do_test_pending();
+    await run_test_with_server(DebuggerServer);
+    await run_test_with_server(WorkerDebuggerServer);
+  } finally {
+    do_test_finished();
+  }
+}
+
+async function run_test_with_server(server) {
+  initTestDebuggerServer(server);
+  const debuggee = addTestGlobal("test-grips", server);
+  debuggee.eval(`
+    function stopMe(arg1) {
+      debugger;
+    }
+  `);
+
+  const dbgClient = new DebuggerClient(server.connectPipe());
+  await dbgClient.connect();
+  const [,, threadClient] = await attachTestTabAndResume(dbgClient, "test-grips");
+
+  await test_object_grip(debuggee, threadClient);
+
+  await dbgClient.close();
+}
+
+async function test_object_grip(debuggee, threadClient) {
+  await assert_object_argument(
+    debuggee,
+    threadClient,
+    `
+      var obj = {
+        stringProp: "a value",
+        get stringNormal(){
+          return "a value";
+        },
+        get stringAbrupt() {
+          throw "a value";
+        },
+        get objectNormal() {
+          return { prop: 4 };
+        },
+        get objectAbrupt() {
+          throw { prop: 4 };
+        },
+        get context(){
+          return this === obj ? "correct context" : "wrong context";
+        },
+        method() {
+          return "a value";
+        },
+      };
+      stopMe(obj);
+    `,
+    async objClient => {
+      const expectedValues = {
+        stringProp: {
+          return: "a value",
+        },
+        stringNormal: {
+          return: "a value",
+        },
+        stringAbrupt: {
+          throw: "a value",
+        },
+        objectNormal: {
+          return: {
+            type: "object",
+            class: "Object",
+            ownPropertyLength: 1,
+            preview: {
+              kind: "Object",
+              ownProperties: {
+                prop: {
+                  value: 4,
+                },
+              },
+            },
+          },
+        },
+        objectAbrupt: {
+          throw: {
+            type: "object",
+            class: "Object",
+            ownPropertyLength: 1,
+            preview: {
+              kind: "Object",
+              ownProperties: {
+                prop: {
+                  value: 4,
+                },
+              },
+            },
+          },
+        },
+        context: {
+          return: "correct context",
+        },
+        method: {
+          return: {
+            type: "object",
+            class: "Function",
+            name: "method",
+          },
+        },
+      };
+
+      for (const [key, expected] of Object.entries(expectedValues)) {
+        const { value } = await objClient.getPropertyValue(key);
+
+        assert_completion(value, expected);
+      }
+    },
+  );
+}
+
+function assert_object_argument(debuggee, threadClient, code, objectHandler) {
+  return new Promise((resolve, reject) => {
+    threadClient.addOneTimeListener("paused", function(event, packet) {
+      (async () => {
+        try {
+          const arg1 = packet.frame.arguments[0];
+          Assert.equal(arg1.class, "Object");
+
+          await objectHandler(threadClient.pauseGrip(arg1));
+        } finally {
+          await threadClient.resume();
+        }
+      })().then(resolve, reject);
+    });
+
+    // This synchronously blocks until 'threadClient.resume()' above runs
+    // because the 'paused' event runs everthing in a new event loop.
+    debuggee.eval(code);
+  });
+}
+
+function assert_completion(value, expected) {
+  if (expected && "return" in expected) {
+    assert_value(value.return, expected.return);
+  }
+  if (expected && "throw" in expected) {
+    assert_value(value.throw, expected.throw);
+  }
+  if (!expected) {
+    assert_value(value, expected);
+  }
+}
+
+function assert_value(actual, expected) {
+  Assert.equal(typeof actual, typeof expected);
+
+  if (typeof expected === "object") {
+    // Note: We aren't using deepEqual here because we're only doing a cursory
+    // check of a few properties, not a full comparison of the result, since
+    // the full outputs includes stuff like preview info that we don't need.
+    for (const key of Object.keys(expected)) {
+      assert_value(actual[key], expected[key]);
+    }
+  } else {
+    Assert.equal(actual, expected);
+  }
+}
--- a/devtools/server/tests/unit/xpcshell.ini
+++ b/devtools/server/tests/unit/xpcshell.ini
@@ -172,16 +172,17 @@ reason = bug 1104838
 [test_objectgrips-16.js]
 [test_objectgrips-17.js]
 [test_objectgrips-18.js]
 [test_objectgrips-19.js]
 [test_objectgrips-20.js]
 [test_objectgrips-21.js]
 [test_objectgrips-22.js]
 [test_objectgrips-array-like-object.js]
+[test_objectgrips-property-value.js]
 [test_promise_state-01.js]
 [test_promise_state-02.js]
 [test_promise_state-03.js]
 [test_interrupt.js]
 [test_stepping-01.js]
 [test_stepping-02.js]
 [test_stepping-03.js]
 [test_stepping-04.js]
--- a/devtools/shared/client/object-client.js
+++ b/devtools/shared/client/object-client.js
@@ -177,16 +177,27 @@ ObjectClient.prototype = {
    * @param onResponse function Called with the request's response.
    */
   getProperty: DebuggerClient.requester({
     type: "property",
     name: arg(0)
   }),
 
   /**
+   * Request the value of the object's specified property.
+   *
+   * @param name string The name of the requested property.
+   * @param onResponse function Called with the request's response.
+   */
+  getPropertyValue: DebuggerClient.requester({
+    type: "propertyValue",
+    name: arg(0)
+  }),
+
+  /**
    * Request the prototype of the object.
    *
    * @param onResponse function Called with the request's response.
    */
   getPrototype: DebuggerClient.requester({
     type: "prototype"
   }),
 
--- a/devtools/shared/specs/object.js
+++ b/devtools/shared/specs/object.js
@@ -19,16 +19,21 @@ types.addDictType("object.descriptor", {
   // Only set `value` exists.
   writable: "nullable:boolean",
   // Only set when `value` does not exist and there is a getter for the property.
   get: "nullable:json",
   // Only set when `value` does not exist and there is a setter for the property.
   set: "nullable:json",
 });
 
+types.addDictType("object.completion", {
+  return: "nullable:json",
+  throw: "nullable:json"
+});
+
 types.addDictType("object.definitionSite", {
   source: "source",
   line: "number",
   column: "number",
 });
 
 types.addDictType("object.prototypeproperties", {
   prototype: "object.descriptor",
@@ -40,16 +45,20 @@ types.addDictType("object.prototypeprope
 types.addDictType("object.prototype", {
   prototype: "object.descriptor",
 });
 
 types.addDictType("object.property", {
   descriptor: "nullable:object.descriptor"
 });
 
+types.addDictType("object.propertyValue", {
+  value: "nullable:object.completion"
+});
+
 types.addDictType("object.bindings", {
   arguments: "array:json",
   variables: "json",
 });
 
 types.addDictType("object.scope", {
   scope: "environment"
 });
@@ -160,16 +169,22 @@ const objectSpec = generateActorSpec({
       response: RetVal("object.prototype")
     },
     property: {
       request: {
         name: Arg(0, "string")
       },
       response: RetVal("object.property")
     },
+    propertyValue: {
+      request: {
+        name: Arg(0, "string")
+      },
+      response: RetVal("object.propertyValue")
+    },
     rejectionStack: {
       request: {},
       response: {
         rejectionStack: RetVal("array:object.originalSourceLocation")
       },
     },
     release: { release: true },
     scope: {