Bug 1548098 - Implement Runtime.callFunctionOn. r=remote-protocol-reviewers,ato
authorAlexandre Poirot <poirot.alex@gmail.com>
Mon, 13 May 2019 16:10:20 +0000
changeset 535513 a0cb021737218a16e04aa0fced67284010f50118
parent 535512 e8a4f66d71d84aa624af0c19a3fe9b87f4891a73
child 535514 55505348c64ff409345e16a6f18c7ae411b02b1f
push id2082
push userffxbld-merge
push dateMon, 01 Jul 2019 08:34:18 +0000
treeherdermozilla-release@2fb19d0466d2 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersremote-protocol-reviewers, ato
bugs1548098
milestone68.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 1548098 - Implement Runtime.callFunctionOn. r=remote-protocol-reviewers,ato Differential Revision: https://phabricator.services.mozilla.com/D30264
remote/domains/content/Runtime.jsm
remote/domains/content/runtime/ExecutionContext.jsm
remote/test/browser/browser.ini
remote/test/browser/browser_runtime_callFunctionOn.js
remote/test/browser/browser_runtime_evaluate.js
remote/test/demo.js
--- a/remote/domains/content/Runtime.jsm
+++ b/remote/domains/content/Runtime.jsm
@@ -72,16 +72,30 @@ class Runtime extends ContentProcessDoma
     }
     if (typeof(request.expression) != "string") {
       throw new Error(`Expecting 'expression' attribute to be a string. ` +
         `But was: ${typeof(request.expression)}`);
     }
     return context.evaluate(request.expression);
   }
 
+  callFunctionOn(request) {
+    const context = this.contexts.get(request.executionContextId);
+    if (!context) {
+      throw new Error(`Unable to find execution context with id: ${request.executionContextId}`);
+    }
+    if (typeof(request.functionDeclaration) != "string") {
+      throw new Error("Expect 'functionDeclaration' attribute to be passed and be a string");
+    }
+    if (request.arguments && !Array.isArray(request.arguments)) {
+      throw new Error("Expect 'arguments' to be an array");
+    }
+    return context.callFunctionOn(request.functionDeclaration, request.arguments);
+  }
+
   get _debugger() {
     if (this.__debugger) {
       return this.__debugger;
     }
     this.__debugger = new Debugger();
     return this.__debugger;
   }
 
--- a/remote/domains/content/runtime/ExecutionContext.jsm
+++ b/remote/domains/content/runtime/ExecutionContext.jsm
@@ -57,17 +57,17 @@ class ExecutionContext {
           text: "Evaluation terminated!",
         },
       };
     }
     if (rv.throw) {
       return this._returnError(rv.throw);
     }
     return {
-      result: this._createRemoteObject(rv.return),
+      result: this._toRemoteObject(rv.return),
     };
   }
 
   /**
    * Given a Debugger.Object reference for an Exception, return a JSON object
    * describing the exception by following CDP ExceptionDetails specification.
    */
   _returnError(exception) {
@@ -85,16 +85,44 @@ class ExecutionContext {
     // If that isn't an Error, consider the exception as a JS value
     return {
       exceptionDetails: {
         exception: this._toRemoteObject(exception),
       },
     };
   }
 
+  async callFunctionOn(functionDeclaration, callArguments = []) {
+    // First evaluate the function
+    const fun = this._debuggee.executeInGlobal("(" + functionDeclaration + ")");
+    if (!fun) {
+      return {
+        exceptionDetails: {
+          text: "Evaluation terminated!",
+        },
+      };
+    }
+    if (fun.throw) {
+      return this._returnError(fun.throw);
+    }
+
+    // Then map all input arguments, which are matching CDP's CallArguments type,
+    // into JS values
+    const args = callArguments.map(arg => this._fromCallArgument(arg));
+
+    // Finally, call the function with these arguments
+    const rv = fun.return.apply(null, args);
+    if (rv.throw) {
+      return this._returnError(rv.throw);
+    }
+    return {
+      result: this._toRemoteObject(rv.return),
+    };
+  }
+
   /**
    * Convert a given `Debugger.Object` to a JSON string.
    *
    * @param {Debugger.Object} obj
    *  The object to convert
    * @return {String}
    *  The JSON string
    */
@@ -102,25 +130,62 @@ class ExecutionContext {
     const result = this._debuggee.executeInGlobalWithBindings("JSON.stringify(e)", {e: obj});
     if (result.throw) {
       throw new Error("Object is not serializable");
     }
     return JSON.parse(result.return);
   }
 
   /**
+   * Given a CDP `CallArgument`, return a JS value that represent this argument.
+   * Note that `CallArgument` is actually very similar to `RemoteObject`
+   */
+  _fromCallArgument(arg) {
+    if (arg.objectId) {
+      if (!this._remoteObjects.has(arg.objectId)) {
+        throw new Error(`Cannot find object with ID: ${arg.objectId}`);
+      }
+      return this._remoteObjects.get(arg.objectId);
+    }
+    if (arg.unserializableValue) {
+      switch (arg.unserializableValue) {
+        case "Infinity": return Infinity;
+        case "-Infinity": return -Infinity;
+        case "-0": return -0;
+        case "NaN": return NaN;
+      }
+    }
+    return this._deserialize(arg.value);
+  }
+
+  /**
+   * Given a JS value, create a copy of it within the debugee compartment.
+   */
+  _deserialize(obj) {
+    if (typeof obj !== "object") {
+      return obj;
+    }
+    const result = this._debuggee.executeInGlobalWithBindings("JSON.parse(obj)",
+      {obj: JSON.stringify(obj)});
+    if (result.throw) {
+      throw new Error("Unable to deserialize object");
+    }
+    return result.return;
+  }
+
+  /**
    * Given a `Debugger.Object` object, return a JSON-serializable description of it
    * matching `RemoteObject` CDP type.
    *
    * @param {Debugger.Object} debuggerObj
    *  The object to serialize
    * @return {RemoteObject}
    *  The serialized description of the given object
    */
-  _createRemoteObject(debuggerObj) {
+  _toRemoteObject(debuggerObj) {
     // First handle all non-primitive values which are going to be wrapped by the
     // Debugger API into Debugger.Object instances
     if (debuggerObj instanceof Debugger.Object) {
       const objectId = uuid();
       this._remoteObjects.set(objectId, debuggerObj);
       const rawObj = debuggerObj.unsafeDereference();
 
       // Map the Debugger API `class` attribute to CDP `subtype`
--- a/remote/test/browser/browser.ini
+++ b/remote/test/browser/browser.ini
@@ -5,12 +5,13 @@ support-files =
   chrome-remote-interface.js
   head.js
 skip-if = debug || asan # bug 1546945
 
 [browser_cdp.js]
 [browser_main_target.js]
 [browser_page_frameNavigated.js]
 [browser_runtime_evaluate.js]
+[browser_runtime_callFunctionOn.js]
 [browser_runtime_executionContext.js]
 skip-if = os == "mac" || (verify && os == 'win') # bug 1547961
 [browser_tabs.js]
 [browser_target.js]
new file mode 100644
--- /dev/null
+++ b/remote/test/browser/browser_runtime_callFunctionOn.js
@@ -0,0 +1,116 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the Runtime.callFunctionOn
+// Also see browser_runtime_evaluate as it covers basic usages of this method.
+
+const TEST_URI = "data:text/html;charset=utf-8,default-test-page";
+
+add_task(async function() {
+  // Open a test page, to prevent debugging the random default page
+  await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URI);
+
+  // Start the CDP server
+  await RemoteAgent.listen(Services.io.newURI("http://localhost:9222"));
+
+  // Retrieve the chrome-remote-interface library object
+  const CDP = await getCDP();
+
+  // Connect to the server
+  const client = await CDP({
+    target(list) {
+      // Ensure debugging the right target, i.e. the one for our test tab.
+      return list.find(target => target.url == TEST_URI);
+    },
+  });
+  ok(true, "CDP client has been instantiated");
+
+  const firstContext = await testRuntimeEnable(client);
+  const contextId = firstContext.id;
+  await testObjectReferences(client, contextId);
+  await testExceptions(client, contextId);
+
+  await client.close();
+  ok(true, "The client is closed");
+
+  BrowserTestUtils.removeTab(gBrowser.selectedTab);
+
+  await RemoteAgent.close();
+});
+
+async function testRuntimeEnable({ Runtime }) {
+  // Enable watching for new execution context
+  await Runtime.enable();
+  ok(true, "Runtime domain has been enabled");
+
+  // Calling Runtime.enable will emit executionContextCreated for the existing contexts
+  const { context } = await Runtime.executionContextCreated();
+  ok(!!context.id, "The execution context has an id");
+  ok(context.auxData.isDefault, "The execution context is the default one");
+  ok(!!context.auxData.frameId, "The execution context has a frame id set");
+
+  return context;
+}
+
+async function testObjectReferences({ Runtime }, contextId) {
+  // First create a JS object remotely via Runtime.evaluate
+  const { result } = await Runtime.evaluate({ contextId, expression: "({ foo: 1 })" });
+  is(result.type, "object", "The type is correct");
+  is(result.subtype, null, "The subtype is null for objects");
+  ok(!!result.objectId, "Got an object id");
+
+  // Then increment the `foo` attribute of this JS object, while returning this
+  // attribute value
+  const { result: result2 } = await Runtime.callFunctionOn({
+    executionContextId: contextId,
+    functionDeclaration: "arg => ++arg.foo",
+    arguments: [{ objectId: result.objectId }],
+  });
+  is(result2.type, "number", "The type is correct");
+  is(result2.subtype, null, "The subtype is null for numbers");
+  is(result2.value, 2, "Updated the existing object and returned the incremented value");
+
+  // Finally, try to pass this JS object and get it back. Ensure that it returns
+  // the same object id. Also increment the attribute again.
+  const { result: result3 } = await Runtime.callFunctionOn({
+    executionContextId: contextId,
+    functionDeclaration: "arg => { arg.foo++; return arg; }",
+    arguments: [{ objectId: result.objectId }],
+  });
+  is(result3.type, "object", "The type is correct");
+  is(result3.subtype, null, "The subtype is null for objects");
+  // Remote object are not having unique id. So you may have multiple object ids
+  // that reference the same remote object
+  ok(!!result3.objectId, "Got an object id");
+  isnot(result3.objectId, result.objectId, "The object id is stable");
+
+  // Assert that we can still access this object and that its foo attribute
+  // has been incremented. Use the second object id we got from previous call
+  // to callFunctionOn.
+  const { result: result4 } = await Runtime.callFunctionOn({
+    executionContextId: contextId,
+    functionDeclaration: "arg => arg.foo",
+    arguments: [{ objectId: result3.objectId }],
+  });
+  is(result4.type, "number", "The type is correct");
+  is(result4.subtype, null, "The subtype is null for numbers");
+  is(result4.value, 3, "Updated the existing object and returned the incremented value");
+}
+
+async function testExceptions({ Runtime }, executionContextId) {
+  // Test error when evaluating the function
+  let { exceptionDetails } = await Runtime.callFunctionOn({
+    executionContextId,
+    functionDeclaration: "doesNotExists()",
+  });
+  is(exceptionDetails.text, "doesNotExists is not defined", "Exception message is passed to the client");
+
+  // Test error when calling the function
+  ({ exceptionDetails } = await Runtime.callFunctionOn({
+    executionContextId,
+    functionDeclaration: "() => doesNotExists()",
+  }));
+  is(exceptionDetails.text, "doesNotExists is not defined", "Exception message is passed to the client");
+}
--- a/remote/test/browser/browser_runtime_evaluate.js
+++ b/remote/test/browser/browser_runtime_evaluate.js
@@ -23,24 +23,78 @@ add_task(async function() {
       // Ensure debugging the right target, i.e. the one for our test tab.
       return list.find(target => target.url == TEST_URI);
     },
   });
   ok(true, "CDP client has been instantiated");
 
   const firstContext = await testRuntimeEnable(client);
   const contextId = firstContext.id;
+
   await testEvaluate(client, contextId);
-  await testInvalidContextId(client, contextId);
-  await testPrimitiveTypes(client, contextId);
-  await testUnserializable(client, contextId);
-  await testObjectTypes(client, contextId);
-  await testThrowError(client, contextId);
-  await testThrowValue(client, contextId);
-  await testJSError(client, contextId);
+  await testEvaluateInvalidContextId(client, contextId);
+
+  await testCallFunctionOn(client, contextId);
+  await testCallFunctionOnInvalidContextId(client, contextId);
+
+  const { Runtime } = client;
+
+  // First test Runtime.evaluate, which accepts an JS expression string.
+  // This string may have instructions separated with `;` before ending
+  // with a JS value that is returned as a CDP `RemoteObject`.
+  function runtimeEvaluate(expression) {
+    return Runtime.evaluate({ contextId, expression });
+  }
+
+  // Then test Runtime.callFunctionOn, which accepts a JS string, but this
+  // time, it has to be a function. In this first test against callFunctionOn,
+  // we only assert the returned type and ignore the arguments.
+  function callFunctionOn(expression, instruction = false) {
+    if (instruction) {
+      return Runtime.callFunctionOn({
+        executionContextId: contextId,
+        functionDeclaration: `() => { ${expression} }`,
+      });
+    }
+    return Runtime.callFunctionOn({
+      executionContextId: contextId,
+      functionDeclaration: `() => ${expression}`,
+    });
+  }
+
+  // Finally, run another test against Runtime.callFunctionOn in order to assert
+  // the arguments being passed to the executed function.
+  async function callFunctionOnArguments(expression, instruction = false) {
+    // First evaluate the expression via Runtime.evaluate in order to generate the
+    // CDP's `RemoteObject` for the given expression. A previous test already
+    // asserted the returned value of Runtime.evaluate, so we can trust this.
+    const { result }  = await Runtime.evaluate({ contextId, expression });
+
+    // We then pass this RemoteObject as an argument to Runtime.callFunctionOn.
+    return Runtime.callFunctionOn({
+      executionContextId: contextId,
+      functionDeclaration: `arg => arg`,
+      arguments: [result],
+    });
+  }
+
+  for (const fun of [runtimeEvaluate, callFunctionOn, callFunctionOnArguments]) {
+    info("Test " + fun.name);
+    await testPrimitiveTypes(fun);
+    await testUnserializable(fun);
+    await testObjectTypes(fun);
+
+    // Tests involving an instruction (exception throwing, or errors) are not
+    // using any argument. So ignore these particular tests.
+    if (fun != callFunctionOnArguments) {
+      await testThrowError(fun);
+      await testThrowValue(fun);
+      await testJSError(fun);
+    }
+  }
 
   await client.close();
   ok(true, "The client is closed");
 
   BrowserTestUtils.removeTab(gBrowser.selectedTab);
 
   await RemoteAgent.close();
 });
@@ -55,61 +109,76 @@ async function testRuntimeEnable({ Runti
   ok(!!context.id, "The execution context has an id");
   ok(context.auxData.isDefault, "The execution context is the default one");
   ok(!!context.auxData.frameId, "The execution context has a frame id set");
 
   return context;
 }
 
 async function testEvaluate({ Runtime }, contextId) {
-  let { result } = await Runtime.evaluate({ contextId, expression: "location.href" });
+  const { result } = await Runtime.evaluate({ contextId, expression: "location.href" });
   is(result.value, TEST_URI, "Runtime.evaluate works and is against the test page");
 }
 
-async function testInvalidContextId({ Runtime }, contextId) {
+async function testEvaluateInvalidContextId({ Runtime }, contextId) {
   try {
     await Runtime.evaluate({ contextId: -1, expression: "" });
     ok(false, "Evaluate shouldn't pass");
   } catch (e) {
     ok(e.message.includes("Unable to find execution context with id: -1"),
       "Throws with the expected error message");
   }
 }
 
-async function testPrimitiveTypes({ Runtime }, contextId) {
+async function testCallFunctionOn({ Runtime }, executionContextId) {
+  const { result } = await Runtime.callFunctionOn({ executionContextId, functionDeclaration: "() => location.href" });
+  is(result.value, TEST_URI, "Runtime.callFunctionOn works and is against the test page");
+}
+
+async function testCallFunctionOnInvalidContextId({ Runtime }, executionContextId) {
+  try {
+    await Runtime.callFunctionOn({ executionContextId: -1, functionDeclaration: "" });
+    ok(false, "callFunctionOn shouldn't pass");
+  } catch (e) {
+    ok(e.message.includes("Unable to find execution context with id: -1"),
+      "Throws with the expected error message");
+  }
+}
+
+async function testPrimitiveTypes(testFunction) {
   const expressions = [42, "42", true, 4.2];
   for (const expression of expressions) {
-    const { result } = await Runtime.evaluate({ contextId, expression: JSON.stringify(expression) });
+    const { result } = await testFunction(JSON.stringify(expression));
     is(result.value, expression, `Evaluating primitive '${expression}' works`);
     is(result.type, typeof(expression), `${expression} type is correct`);
   }
 
   // undefined doesn't work with JSON.stringify, so test it independently
-  let { result } = await Runtime.evaluate({ contextId, expression: "undefined" });
+  let { result } = await testFunction("undefined");
   is(result.value, undefined, "undefined works");
   is(result.type, "undefined", "undefined type is correct");
 
   // `null` is special as it has its own subtype, is of type 'object' but is returned as
   // a value, without an `objectId` attribute
-  ({ result } = await Runtime.evaluate({ contextId, expression: "null" }));
+  ({ result } = await testFunction("null"));
   is(result.value, null, "Evaluating 'null' works");
   is(result.type, "object", "'null' type is correct");
   is(result.subtype, "null", "'null' subtype is correct");
   ok(!result.objectId, "'null' has no objectId");
 }
 
-async function testUnserializable({ Runtime }, contextId) {
+async function testUnserializable(testFunction) {
   const expressions = ["NaN", "-0", "Infinity", "-Infinity"];
   for (const expression of expressions) {
-    const { result } = await Runtime.evaluate({ contextId, expression });
+    const { result } = await testFunction(expression);
     is(result.unserializableValue, expression, `Evaluating unserializable '${expression}' works`);
   }
 }
 
-async function testObjectTypes({ Runtime }, contextId) {
+async function testObjectTypes(testFunction) {
   const expressions = [
     { expression: "({foo:true})", type: "object", subtype: null },
     { expression: "Symbol('foo')", type: "symbol", subtype: null },
     { expression: "BigInt(42)", type: "bigint", subtype: null },
     { expression: "new Promise(()=>{})", type: "object", subtype: "promise" },
     { expression: "new Int8Array(8)", type: "object", subtype: "typedarray" },
     { expression: "new WeakMap()", type: "object", subtype: "weakmap" },
     { expression: "new WeakSet()", type: "object", subtype: "weakset" },
@@ -117,30 +186,30 @@ async function testObjectTypes({ Runtime
     { expression: "new Set()", type: "object", subtype: "set" },
     { expression: "/foo/", type: "object", subtype: "regexp" },
     { expression: "[1, 2]", type: "object", subtype: "array" },
     { expression: "new Proxy({}, {})", type: "object", subtype: "proxy" },
     { expression: "new Date()", type: "object", subtype: "date" },
   ];
 
   for (const { expression, type, subtype } of expressions) {
-    const { result } = await Runtime.evaluate({ contextId, expression });
+    const { result } = await testFunction(expression);
     is(result.subtype, subtype, `Evaluating '${expression}' has the expected subtype`);
     is(result.type, type, "The type is correct");
     ok(!!result.objectId, "Got an object id");
   }
 }
 
-async function testThrowError({ Runtime }, contextId) {
-  const { exceptionDetails } = await Runtime.evaluate({ contextId, expression: "throw new Error('foo')" });
+async function testThrowError(testFunction) {
+  const { exceptionDetails } = await testFunction("throw new Error('foo')", true);
   is(exceptionDetails.text, "foo", "Exception message is passed to the client");
 }
 
-async function testThrowValue({ Runtime }, contextId) {
-  const { exceptionDetails } = await Runtime.evaluate({ contextId, expression: "throw 'foo'" });
+async function testThrowValue(testFunction) {
+  const { exceptionDetails } = await testFunction("throw 'foo'", true);
   is(exceptionDetails.exception.type, "string", "Exception type is correct");
   is(exceptionDetails.exception.value, "foo", "Exception value is passed as a RemoteObject");
 }
 
-async function testJSError({ Runtime }, contextId) {
-  const { exceptionDetails } = await Runtime.evaluate({ contextId, expression: "doesNotExists()" });
+async function testJSError(testFunction) {
+  const { exceptionDetails } = await testFunction("doesNotExists()", true);
   is(exceptionDetails.text, "doesNotExists is not defined", "Exception message is passed to the client");
 }
--- a/remote/test/demo.js
+++ b/remote/test/demo.js
@@ -1,17 +1,24 @@
 "use strict";
 
 const CDP = require("chrome-remote-interface");
 
 async function demo() {
   let client;
   try {
     client = await CDP();
-    const {Log, Network, Page} = client;
+    const {Log, Network, Page, Runtime} = client;
+    let { result } = await Runtime.evaluate({expression: "this.obj = {foo:true}; this.obj"});
+    console.log("1", result);
+    ({ result } = await Runtime.evaluate({expression: "this.obj"}));
+    console.log("2", result);
+    ({ result } = await Runtime.evaluate({expression: "this.obj.foo"}));
+    console.log("3", result);
+
 
     // receive console.log messages and print them
     Log.enable();
     Log.entryAdded(({entry}) => {
       const {timestamp, level, text, args} = entry;
       const msg = text || args.join(" ");
       console.log(`${new Date(timestamp)}\t${level.toUpperCase()}\t${msg}`);
     });