Bug 1473996 - Expose fn.apply in the devtools server. r=nchevobbe
authorLogan F Smyth <loganfsmyth@gmail.com>
Wed, 26 Sep 2018 16:23:25 +0000
changeset 438318 8c9d2be6d47e0a530e161430f06f47678c25fe3f
parent 438317 df8f12fd43e7829fd9b96e96e0ae063b6d971fe2
child 438319 513ebcc0f395531d2bfea60154515dee8c7987d0
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 fn.apply in the devtools server. r=nchevobbe Depends on D6722 Differential Revision: https://phabricator.services.mozilla.com/D6723
devtools/server/actors/object.js
devtools/server/tests/unit/test_objectgrips-fn-apply-01.js
devtools/server/tests/unit/test_objectgrips-fn-apply-02.js
devtools/server/tests/unit/test_objectgrips-fn-apply-03.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
@@ -525,16 +525,61 @@ const proto = {
     }
 
     const value = this.obj.getProperty(name);
 
     return { value: this._buildCompletion(value) };
   },
 
   /**
+   * 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) {
+    if (!this.obj.callable) {
+      return this.throwError("notCallable", "debugee object is not callable");
+    }
+
+    const debugeeContext = this._getValueFromGrip(context);
+    const debugeeArgs = args && args.map(this._getValueFromGrip, this);
+
+    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-01.js
@@ -0,0 +1,154 @@
+/* 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 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_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);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/devtools/server/tests/unit/test_objectgrips-fn-apply-02.js
@@ -0,0 +1,88 @@
+/* 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({
+      method(){
+        debugger;
+      },
+    });
+  `;
+  const obj = await eval_and_resume(debuggee, threadClient, code, async frame => {
+    const arg1 = frame.arguments[0];
+    Assert.equal(arg1.class, "Object");
+
+    await threadClient.pauseGrip(arg1).threadGrip();
+    return arg1;
+  });
+  const objClient = threadClient.pauseGrip(obj);
+
+  const method = threadClient.pauseGrip(
+    (await objClient.getPropertyValue("method")).value.return,
+  );
+
+  // 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);
+    }),
+    method.apply(obj, []),
+  ]);
+}
+
+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);
+    });
+  });
+}
new file mode 100644
--- /dev/null
+++ b/devtools/server/tests/unit/test_objectgrips-fn-apply-03.js
@@ -0,0 +1,87 @@
+/* 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);
+
+    // TODO: The server currently doesn't handle request errors
+    // in workers well, so this test doesn't work with the worker server.
+    // 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({
+      method: {},
+    });
+  `;
+  const obj = await eval_and_resume(debuggee, threadClient, code, async frame => {
+    const arg1 = frame.arguments[0];
+    Assert.equal(arg1.class, "Object");
+
+    await threadClient.pauseGrip(arg1).threadGrip();
+    return arg1;
+  });
+  const objClient = threadClient.pauseGrip(obj);
+
+  const method = threadClient.pauseGrip(
+    (await objClient.getPropertyValue("method")).value.return,
+  );
+
+  try {
+    await method.apply(obj, []);
+    Assert.ok(false, "expected exception");
+  } catch (err) {
+    Assert.equal(err.message, "debugee object is not callable");
+  }
+}
+
+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
@@ -174,16 +174,19 @@ reason = bug 1104838
 [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_objectgrips-fn-apply-01.js]
+[test_objectgrips-fn-apply-02.js]
+[test_objectgrips-fn-apply-03.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
@@ -201,16 +201,29 @@ ObjectClient.prototype = {
    *
    * @param onResponse function Called with the request's response.
    */
   getPrototype: DebuggerClient.requester({
     type: "prototype"
   }),
 
   /**
+   * Evaluate a callable object with context and arguments.
+   *
+   * @param context any The value to use as the function context.
+   * @param arguments Array<any> An array of values to use as the function's arguments.
+   * @param onResponse function Called with the request's response.
+   */
+  apply: DebuggerClient.requester({
+    type: "apply",
+    context: 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,19 +49,23 @@ 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",
+  variables: "json"
 });
 
 types.addDictType("object.scope", {
   scope: "environment"
 });
 
 types.addDictType("object.enumProperties.Options", {
   enumEntries: "nullable:boolean",
@@ -175,16 +179,23 @@ const objectSpec = generateActorSpec({
       response: RetVal("object.property")
     },
     propertyValue: {
       request: {
         name: Arg(0, "string")
       },
       response: RetVal("object.propertyValue")
     },
+    apply: {
+      request: {
+        context: 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: {