Bug 1473996 - Expose getPropertyValue in devtools server to fully evaluate an object property. r=nchevobbe
authorLogan F Smyth <loganfsmyth@gmail.com>
Wed, 26 Sep 2018 16:12:56 +0000
changeset 438317 df8f12fd43e7829fd9b96e96e0ae063b6d971fe2
parent 438316 3387f039baf966ab1ae0cb9ee226409208f7d26c
child 438318 8c9d2be6d47e0a530e161430f06f47678c25fe3f
push id34716
push usershindli@mozilla.com
push dateWed, 26 Sep 2018 21:51:41 +0000
treeherdermozilla-central@f8b2114ab512 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersnchevobbe
bugs1473996
milestone64.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 Differential Revision: https://phabricator.services.mozilla.com/D6722
devtools/server/actors/object.js
devtools/server/actors/thread.js
devtools/server/tests/unit/test_objectgrips-property-value-01.js
devtools/server/tests/unit/test_objectgrips-property-value-02.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
@@ -503,16 +503,63 @@ 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.obj.getProperty(name);
+
+    return { value: this._buildCompletion(value) };
+  },
+
+  /**
+   * 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) };
   },
 
   /**
--- a/devtools/server/actors/thread.js
+++ b/devtools/server/actors/thread.js
@@ -1605,17 +1605,23 @@ const ThreadActor = ActorClassWithSpec(t
     if (this.threadLifetimePool.objectActors.has(value)) {
       return this.threadLifetimePool.objectActors.get(value).form();
     }
 
     const actor = new PauseScopedObjectActor(value, {
       getGripDepth: () => this._gripDepth,
       incrementGripDepth: () => this._gripDepth++,
       decrementGripDepth: () => this._gripDepth--,
-      createValueGrip: v => createValueGrip(v, this._pausePool, this.pauseObjectGrip),
+      createValueGrip: v => {
+        if (this._pausePool) {
+          return createValueGrip(v, this._pausePool, this.pauseObjectGrip);
+        }
+
+        return createValueGrip(v, this.threadLifetimePool, this.objectGrip);
+      },
       sources: () => this.sources,
       createEnvironmentActor: (e, p) => this.createEnvironmentActor(e, p),
       promote: () => this.threadObjectGrip(actor),
       isThreadLifetimePool: () => actor.registeredPool !== this.threadLifetimePool,
       getGlobalDebugObject: () => this.globalDebugObject
     }, this.conn);
     pool.addActor(actor);
     pool.objectActors.set(value, actor);
new file mode 100644
--- /dev/null
+++ b/devtools/server/tests/unit/test_objectgrips-property-value-01.js
@@ -0,0 +1,183 @@
+/* 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 eval_and_resume(debuggee, threadClient, code, async frame => {
+    const arg1 = frame.arguments[0];
+    Assert.equal(arg1.class, "Object");
+
+    await objectHandler(threadClient.pauseGrip(arg1));
+  });
+}
+
+function eval_and_resume(debuggee, threadClient, code, callback) {
+  return new Promise((resolve, reject) => {
+    wait_for_pause(threadClient, callback).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 wait_for_pause(threadClient, callback = () => {}) {
+  return new Promise((resolve, reject) => {
+    threadClient.addOneTimeListener("paused", function(event, packet) {
+      (async () => {
+        try {
+          return await callback(packet.frame);
+        } finally {
+          await threadClient.resume();
+        }
+      })().then(resolve, reject);
+    });
+  });
+}
+
+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);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/devtools/server/tests/unit/test_objectgrips-property-value-02.js
@@ -0,0 +1,84 @@
+/* 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) {
+  const code = `
+    stopMe({
+      get prop(){
+        debugger;
+      },
+    });
+  `;
+  const objClient = await eval_and_resume(debuggee, threadClient, code, async frame => {
+    const arg1 = frame.arguments[0];
+    Assert.equal(arg1.class, "Object");
+
+    const obj = threadClient.pauseGrip(arg1);
+    await obj.threadGrip();
+    return obj;
+  });
+
+  // Ensure that we actually paused at the `debugger;` line.
+  await Promise.all([
+    wait_for_pause(threadClient, frame => {
+      Assert.equal(frame.where.line, 4);
+      Assert.equal(frame.where.column, 8);
+    }),
+    objClient.getPropertyValue("prop"),
+  ]);
+}
+
+function eval_and_resume(debuggee, threadClient, code, callback) {
+  return new Promise((resolve, reject) => {
+    wait_for_pause(threadClient, callback).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 wait_for_pause(threadClient, callback = () => {}) {
+  return new Promise((resolve, reject) => {
+    threadClient.addOneTimeListener("paused", function(event, packet) {
+      (async () => {
+        try {
+          return await callback(packet.frame);
+        } finally {
+          await threadClient.resume();
+        }
+      })().then(resolve, reject);
+    });
+  });
+}
--- a/devtools/server/tests/unit/xpcshell.ini
+++ b/devtools/server/tests/unit/xpcshell.ini
@@ -172,16 +172,18 @@ 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-01.js]
+[test_objectgrips-property-value-02.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
@@ -37,16 +37,20 @@ ObjectClient.prototype = {
   },
   get isSealed() {
     return this._grip.sealed;
   },
   get isExtensible() {
     return this._grip.extensible;
   },
 
+  threadGrip: DebuggerClient.requester({
+    type: "threadGrip",
+  }),
+
   getDefinitionSite: DebuggerClient.requester({
     type: "definitionSite"
   }, {
     before: function(packet) {
       if (this._grip.class != "Function") {
         throw new Error("getDefinitionSite is only valid for function grips.");
       }
       return packet;
@@ -177,16 +181,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: {