Bug 1473996 - Expose getPropertyValue in devtools server to fully evaluate an object property. r=nchevobbe
☠☠ backed out by be97edb6e793 ☠ ☠
authorLogan F Smyth <loganfsmyth@gmail.com>
Thu, 12 Jul 2018 11:08:38 -0700
changeset 427013 9ba8a6f1857d16d3389c81f3323df1149ae94841
parent 427012 162af260bace9f34f633ae743a748e415490b03f
child 427014 dc601e6050f67f30980aabbf13e179790f4a4485
push id66490
push usernchevobbe@mozilla.com
push dateWed, 18 Jul 2018 05:48:57 +0000
treeherderautoland@dc601e6050f6 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersnchevobbe
bugs1473996
milestone63.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
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: {