Bug 1473996 - Expose fn.apply in the devtools server. r=nchevobbe draft
authorLogan F Smyth <loganfsmyth@gmail.com>
Thu, 12 Jul 2018 11:09:54 -0700
changeset 817511 3811894117519577c048a28b5ca5e009ea707391
parent 817510 0f35c1ded9923b8a7b35a8af924a3b1cbf53fab9
push id116081
push userbmo:loganfsmyth@gmail.com
push dateThu, 12 Jul 2018 18:10:54 +0000
reviewersnchevobbe
bugs1473996
milestone63.0a1
Bug 1473996 - Expose fn.apply in the devtools server. r=nchevobbe MozReview-Commit-ID: 33Uh8UIuz4g
devtools/server/actors/object.js
devtools/server/tests/unit/test_objectgrips-fn-apply.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
@@ -532,16 +532,61 @@ const proto = {
       return debugeeGetter.call(undefined, obj, key);
     };
     propertyValueGettersMap.set(global, getter);
 
     return getter;
   },
 
   /**
+   * Handle a protocol request to evaluate a function and provide the value of
+   * the result.
+   *
+   * Note: Since this will evaluate the function, 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 {any} context
+   *        The 'this' value to call the function with.
+   * @param {Array<any>} args
+   *        The array of un-decoded actor objects, or primitives.
+   */
+  apply: function(context, args) {
+    const debugeeContext = this._getValueFromGrip(context);
+    const debugeeArgs = args && args.map(this._getValueFromGrip, this);
+
+    if (!this.obj.callable) {
+      return this.throwError("notCallable", "debugee object is not callable");
+    }
+
+    const value = this.obj.apply(debugeeContext, debugeeArgs);
+
+    return { value: this._buildCompletion(value) };
+  },
+
+  _getValueFromGrip(grip) {
+    if (typeof grip !== "object" || !grip) {
+      return grip;
+    }
+
+    if (typeof grip.actor !== "string") {
+      return this.throwError("invalidGrip", "grip argument did not include actor ID");
+    }
+
+    const actor = this.conn.getActor(grip.actor);
+
+    if (!actor) {
+      return this.throwError("unknownActor", "grip actor did not match a known object");
+    }
+
+    return actor.obj;
+  },
+
+  /**
    * 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;
new file mode 100644
--- /dev/null
+++ b/devtools/server/tests/unit/test_objectgrips-fn-apply.js
@@ -0,0 +1,142 @@
+/* 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,
+    `
+      stopMe({
+        obj1: {},
+        obj2: {},
+        context(arg) {
+          return this === arg ? "correct context" : "wrong context";
+        },
+        sum(...parts) {
+          return parts.reduce((acc, v) => acc + v, 0);
+        },
+        error() {
+          throw "an error";
+        },
+      });
+    `,
+    async objClient => {
+      const obj1 = (await objClient.getPropertyValue("obj1")).value.return;
+      const obj2 = (await objClient.getPropertyValue("obj2")).value.return;
+
+      const context = threadClient.pauseGrip(
+        (await objClient.getPropertyValue("context")).value.return,
+      );
+      const sum = threadClient.pauseGrip(
+        (await objClient.getPropertyValue("sum")).value.return,
+      );
+      const error = threadClient.pauseGrip(
+        (await objClient.getPropertyValue("error")).value.return,
+      );
+
+      assert_response(await context.apply(obj1, [obj1]), {
+        return: "correct context",
+      });
+      assert_response(await context.apply(obj2, [obj2]), {
+        return: "correct context",
+      });
+      assert_response(await context.apply(obj1, [obj2]), {
+        return: "wrong context",
+      });
+      assert_response(await context.apply(obj2, [obj1]), {
+        return: "wrong context",
+      });
+      // eslint-disable-next-line no-useless-call
+      assert_response(await sum.apply(null, [1, 2, 3, 4, 5, 6, 7]), {
+        return: 1 + 2 + 3 + 4 + 5 + 6 + 7,
+      });
+      // eslint-disable-next-line no-useless-call
+      assert_response(await error.apply(null, []), {
+        throw: "an error",
+      });
+    },
+  );
+}
+
+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_response({ value }, expected) {
+  assert_completion(value, expected);
+}
+
+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-fn-apply.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]
--- a/devtools/shared/client/object-client.js
+++ b/devtools/shared/client/object-client.js
@@ -197,16 +197,28 @@ ObjectClient.prototype = {
    *
    * @param onResponse function Called with the request's response.
    */
   getPrototype: DebuggerClient.requester({
     type: "prototype"
   }),
 
   /**
+   * 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.
+   */
+  apply: DebuggerClient.requester({
+    type: "apply",
+    this: arg(0),
+    arguments: arg(1),
+  }),
+
+  /**
    * Request the display string of the object.
    *
    * @param onResponse function Called with the request's response.
    */
   getDisplayString: DebuggerClient.requester({
     type: "displayString"
   }),
 
--- a/devtools/shared/specs/object.js
+++ b/devtools/shared/specs/object.js
@@ -49,16 +49,20 @@ types.addDictType("object.prototype", {
 types.addDictType("object.property", {
   descriptor: "nullable:object.descriptor"
 });
 
 types.addDictType("object.propertyValue", {
   value: "nullable:object.completion"
 });
 
+types.addDictType("object.apply", {
+  value: "nullable:object.completion"
+});
+
 types.addDictType("object.bindings", {
   arguments: "array:json",
   variables: "json",
 });
 
 types.addDictType("object.scope", {
   scope: "environment"
 });
@@ -175,16 +179,23 @@ const objectSpec = generateActorSpec({
       response: RetVal("object.property")
     },
     propertyValue: {
       request: {
         name: Arg(0, "string")
       },
       response: RetVal("object.propertyValue")
     },
+    apply: {
+      request: {
+        this: Arg(0, "nullable:json"),
+        arguments: Arg(1, "nullable:array:json"),
+      },
+      response: RetVal("object.apply")
+    },
     rejectionStack: {
       request: {},
       response: {
         rejectionStack: RetVal("array:object.originalSourceLocation")
       },
     },
     release: { release: true },
     scope: {