Bug 1543099 - Implement Runtime.evaluate. r=remote-protocol-reviewers,ato
authorAlexandre Poirot <poirot.alex@gmail.com>
Thu, 02 May 2019 11:55:55 +0000
changeset 531250 6c9b27fd6233665660bd68527c2f20a892bb7ad2
parent 531249 7c074ab51d55943c159cda38823958487967c7fc
child 531251 af2d798ce6b375492d44d7ed0b4060faad823bb5
push id11265
push userffxbld-merge
push dateMon, 13 May 2019 10:53:39 +0000
treeherdermozilla-beta@77e0fe8dbdd3 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersremote-protocol-reviewers, ato
bugs1543099
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 1543099 - Implement Runtime.evaluate. r=remote-protocol-reviewers,ato Differential Revision: https://phabricator.services.mozilla.com/D27525
remote/domains/content/Runtime.jsm
remote/domains/content/runtime/ExecutionContext.jsm
remote/jar.mn
remote/test/browser/browser.ini
remote/test/browser/browser_runtime_evaluate.js
remote/test/browser/browser_runtime_executionContext.js
--- a/remote/domains/content/Runtime.jsm
+++ b/remote/domains/content/Runtime.jsm
@@ -3,21 +3,30 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 var EXPORTED_SYMBOLS = ["Runtime"];
 
 const {ContentProcessDomain} = ChromeUtils.import("chrome://remote/content/domains/ContentProcessDomain.jsm");
 const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const {ExecutionContext} = ChromeUtils.import("chrome://remote/content/domains/content/runtime/ExecutionContext.jsm");
+const {addDebuggerToGlobal} = ChromeUtils.import("resource://gre/modules/jsdebugger.jsm", {});
+
+// Import the `Debugger` constructor in the current scope
+addDebuggerToGlobal(Cu.getGlobalForObject(this));
 
 class Runtime extends ContentProcessDomain {
   constructor(session) {
     super(session);
     this.enabled = false;
+
+    // Map of all the ExecutionContext instances:
+    // [Execution context id (Number) => ExecutionContext instance]
+    this.contexts = new Map();
   }
 
   destructor() {
     this.disable();
   }
 
   // commands
 
@@ -33,27 +42,17 @@ class Runtime extends ContentProcessDoma
       this.chromeEventHandler.addEventListener("pagehide", this,
         {mozSystemGroup: true});
 
       Services.obs.addObserver(this, "inner-window-destroyed");
 
       // Spin the event loop in order to send the `executionContextCreated` event right
       // after we replied to `enable` request.
       Services.tm.dispatchToMainThread(() => {
-        const frameId = this.content.windowUtils.outerWindowID;
-        const id = this.content.windowUtils.currentInnerWindowID;
-        this.emit("Runtime.executionContextCreated", {
-          context: {
-            id,
-            auxData: {
-              isDefault: true,
-              frameId,
-            },
-          },
-        });
+        this._createContext(this.content);
       });
     }
   }
 
   disable() {
     if (this.enabled) {
       this.enabled = false;
       this.chromeEventHandler.removeEventListener("DOMWindowCreated", this,
@@ -61,59 +60,111 @@ class Runtime extends ContentProcessDoma
       this.chromeEventHandler.removeEventListener("pageshow", this,
         {mozSystemGroup: true});
       this.chromeEventHandler.removeEventListener("pagehide", this,
         {mozSystemGroup: true});
       Services.obs.removeObserver(this, "inner-window-destroyed");
     }
   }
 
+  evaluate(request) {
+    const context = this.contexts.get(request.contextId);
+    if (!context) {
+      throw new Error(`Unable to find execution context with id: ${request.contextId}`);
+    }
+    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);
+  }
+
+  get _debugger() {
+    if (this.__debugger) {
+      return this.__debugger;
+    }
+    this.__debugger = new Debugger();
+    return this.__debugger;
+  }
+
   handleEvent({type, target, persisted}) {
-    const frameId = target.defaultView.windowUtils.outerWindowID;
-    const id = target.defaultView.windowUtils.currentInnerWindowID;
+    if (target.defaultView != this.content) {
+      // Ignore iframes for now.
+      return;
+    }
     switch (type) {
     case "DOMWindowCreated":
-      this.emit("Runtime.executionContextCreated", {
-        context: {
-          id,
-          auxData: {
-            isDefault: target == this.content.document,
-            frameId,
-          },
-        },
-      });
+      this._createContext(target.defaultView);
       break;
 
     case "pageshow":
       // `persisted` is true when this is about a page being resurected from BF Cache
       if (!persisted) {
         return;
       }
-      this.emit("Runtime.executionContextCreated", {
-        context: {
-          id,
-          auxData: {
-            isDefault: target == this.content.document,
-            frameId,
-          },
-        },
-      });
+      this._createContext(target.defaultView);
       break;
 
     case "pagehide":
       // `persisted` is true when this is about a page being frozen into BF Cache
       if (!persisted) {
         return;
       }
-      this.emit("Runtime.executionContextDestroyed", {
-        executionContextId: id,
-      });
+      const id = target.defaultView.windowUtils.currentInnerWindowID;
+      this._destroyContext(id);
       break;
     }
   }
 
   observe(subject, topic, data) {
     const innerWindowID = subject.QueryInterface(Ci.nsISupportsPRUint64).data;
-    this.emit("Runtime.executionContextDestroyed", {
-      executionContextId: innerWindowID,
+    this._destroyContext(innerWindowID);
+  }
+
+  /**
+   * Helper method in order to instantiate the ExecutionContext for a given
+   * DOM Window as well as emitting the related `Runtime.executionContextCreated`
+   * event.
+   *
+   * @param {Window} window
+   *     The window object of the newly instantiated document.
+   */
+  _createContext(window) {
+    const { windowUtils } = window;
+    const id = windowUtils.currentInnerWindowID;
+    if (this.contexts.has(id)) {
+      return;
+    }
+
+    const context = new ExecutionContext(this._debugger, window);
+    this.contexts.set(id, context);
+
+    const frameId = windowUtils.outerWindowID;
+    this.emit("Runtime.executionContextCreated", {
+      context: {
+        id,
+        auxData: {
+          isDefault: window == this.content,
+          frameId,
+        },
+      },
     });
   }
+
+  /**
+   * Helper method to destroy the ExecutionContext of the given id. Also emit
+   * the related `Runtime.executionContextDestroyed` event.
+   *
+   * @param {Number} id
+   *     The execution context id to destroy.
+   */
+  _destroyContext(id) {
+    const context = this.contexts.get(id);
+
+    if (context) {
+      context.destructor();
+      this.contexts.delete(id);
+      this.emit("Runtime.executionContextDestroyed", {
+        executionContextId: id,
+      });
+    }
+  }
 }
new file mode 100644
--- /dev/null
+++ b/remote/domains/content/runtime/ExecutionContext.jsm
@@ -0,0 +1,189 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["ExecutionContext"];
+
+const uuidGen = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);
+
+const TYPED_ARRAY_CLASSES = ["Uint8Array", "Uint8ClampedArray", "Uint16Array",
+                             "Uint32Array", "Int8Array", "Int16Array", "Int32Array",
+                             "Float32Array", "Float64Array"];
+function uuid() {
+  return uuidGen.generateUUID().toString().slice(1, -1);
+}
+
+/**
+ * This class represent a debuggable context onto which we can evaluate Javascript.
+ * This is typically a document, but it could also be a worker, an add-on, ... or
+ * any kind of context involving JS scripts.
+ *
+ * @param {Debugger} dbg
+ *   A Debugger instance that we can use to inspect the given global.
+ * @param {GlobalObject} debuggee
+ *   The debuggable context's global object. This is typically the document window
+ *   object. But it can also be any global object, like a worker global scope object.
+ */
+class ExecutionContext {
+  constructor(dbg, debuggee) {
+    this._debugger = dbg;
+    this._debuggee = this._debugger.addDebuggee(debuggee);
+
+    this._remoteObjects = new Map();
+  }
+
+  destructor() {
+    this._debugger.removeDebuggee(this._debuggee);
+  }
+
+  /**
+   * Evaluate a Javascript expression.
+   *
+   * @param {String} expression
+   *   The JS expression to evaluate against the JS context.
+   * @return {Object} A multi-form object depending if the execution succeed or failed.
+   *   If the expression failed to evaluate, it will return an object with an
+   *   `exceptionDetails` attribute matching the `ExceptionDetails` CDP type.
+   *   Otherwise it will return an object with `result` attribute whose type is
+   *   `RemoteObject` CDP type.
+   */
+  evaluate(expression) {
+    let rv = this._debuggee.executeInGlobal(expression);
+    if (!rv) {
+      return {
+        exceptionDetails: {
+          text: "Evaluation terminated!",
+        },
+      };
+    }
+    if (rv.throw) {
+      if (this._debuggee.executeInGlobalWithBindings("e instanceof Error", {e: rv.throw}).return) {
+        return {
+          exceptionDetails: {
+            text: this._debuggee.executeInGlobalWithBindings("e.message", {e: rv.throw}).return,
+          },
+        };
+      }
+      return {
+        exceptionDetails: {
+          exception: this._createRemoteObject(rv.throw),
+        },
+      };
+    }
+    return {
+      result: this._createRemoteObject(rv.return),
+    };
+  }
+
+  /**
+   * Convert a given `Debugger.Object` to a JSON string.
+   *
+   * @param {Debugger.Object} obj
+   *  The object to convert
+   * @return {String}
+   *  The JSON string
+   */
+  _serialize(obj) {
+    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 `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) {
+    // 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`
+      const cls = debuggerObj.class;
+      let subtype;
+      if (debuggerObj.isProxy) {
+        subtype = "proxy";
+      } else if (cls == "Array") {
+        subtype = "array";
+      } else if (cls == "RegExp") {
+        subtype = "regexp";
+      } else if (cls == "Date") {
+        subtype = "date";
+      } else if (cls == "Map") {
+        subtype = "map";
+      } else if (cls == "Set") {
+        subtype = "set";
+      } else if (cls == "WeakMap") {
+        subtype = "weakmap";
+      } else if (cls == "WeakSet") {
+        subtype = "weakset";
+      } else if (cls == "Error") {
+        subtype = "error";
+      } else if (cls == "Promise") {
+        subtype = "promise";
+      } else if (TYPED_ARRAY_CLASSES.includes(cls)) {
+        subtype = "typedarray";
+      } else if (cls == "Object" && Node.isInstance(rawObj)) {
+        subtype = "node";
+      }
+
+      const type = typeof rawObj;
+      return {objectId, type, subtype};
+    }
+
+    // Now, handle all values that Debugger API isn't wrapping into Debugger.API.
+    // This is all the primitive JS types.
+    const type = typeof debuggerObj;
+
+    // Symbol and BigInt are primitive values but aren't serializable.
+    // CDP expects them to be considered as objects, with an objectId to later inspect
+    // them.
+    if (type == "symbol" || type == "bigint") {
+      const objectId = uuid();
+      this._remoteObjects.set(objectId, debuggerObj);
+      return {objectId, type};
+    }
+
+    // A few primitive type can't be serialized and CDP has special case for them
+    let unserializableValue = undefined;
+    if (Object.is(debuggerObj, NaN))
+      unserializableValue = "NaN";
+    else if (Object.is(debuggerObj, -0))
+      unserializableValue = "-0";
+    else if (Object.is(debuggerObj, Infinity))
+      unserializableValue = "Infinity";
+    else if (Object.is(debuggerObj, -Infinity))
+      unserializableValue = "-Infinity";
+    if (unserializableValue) {
+      return {
+        unserializableValue,
+      };
+    }
+
+    // Otherwise, we serialize the primitive values as-is via `value` attribute
+
+    // null is special as it has a dedicated subtype
+    let subtype;
+    if (debuggerObj === null) {
+      subtype = "null";
+    }
+
+    return {
+      type,
+      subtype,
+      value: debuggerObj,
+    };
+  }
+}
--- a/remote/jar.mn
+++ b/remote/jar.mn
@@ -37,16 +37,17 @@ remote.jar:
   content/domains/content/DOM.jsm (domains/content/DOM.jsm)
   content/domains/content/Emulation.jsm (domains/content/Emulation.jsm)
   content/domains/content/Input.jsm (domains/content/Input.jsm)
   content/domains/content/Log.jsm (domains/content/Log.jsm)
   content/domains/content/Network.jsm (domains/content/Network.jsm)
   content/domains/content/Page.jsm (domains/content/Page.jsm)
   content/domains/content/Performance.jsm (domains/content/Performance.jsm)
   content/domains/content/Runtime.jsm (domains/content/Runtime.jsm)
+  content/domains/content/runtime/ExecutionContext.jsm (domains/content/runtime/ExecutionContext.jsm)
   content/domains/content/Security.jsm (domains/content/Security.jsm)
   content/domains/parent/Browser.jsm (domains/parent/Browser.jsm)
   content/domains/parent/Target.jsm (domains/parent/Target.jsm)
 
   # transport layer
   content/server/HTTPD.jsm (../netwerk/test/httpserver/httpd.js)
   content/server/Stream.jsm (server/Stream.jsm)
   content/server/WebSocket.jsm (server/WebSocket.jsm)
--- a/remote/test/browser/browser.ini
+++ b/remote/test/browser/browser.ini
@@ -4,12 +4,13 @@ prefs = remote.enabled=true
 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_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_evaluate.js
@@ -0,0 +1,167 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/* global getCDP */
+
+const {RemoteAgent} = ChromeUtils.import("chrome://remote/content/RemoteAgent.jsm");
+const {RemoteAgentError} = ChromeUtils.import("chrome://remote/content/Error.jsm");
+
+// Test the Runtime execution context events
+
+const TEST_URI = "data:text/html;charset=utf-8,default-test-page";
+
+add_task(async function() {
+  try {
+    await testCDP();
+  } catch (e) {
+    // Display better error message with the server side stacktrace
+    // if an error happened on the server side:
+    if (e.response) {
+      throw RemoteAgentError.fromJSON(e.response);
+    } else {
+      throw e;
+    }
+  }
+});
+
+async function testCDP() {
+  // Open a test page, to prevent debugging the random default page
+  await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URI);
+
+  // Start the CDP server
+  RemoteAgent.init();
+  RemoteAgent.tabs.start();
+  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 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 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 testEvaluate({ Runtime }, contextId) {
+  let { 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) {
+  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) {
+  const expressions = [42, "42", true, 4.2];
+  for (const expression of expressions) {
+    const { result } = await Runtime.evaluate({ contextId, expression: 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" });
+  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" }));
+  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) {
+  const expressions = ["NaN", "-0", "Infinity", "-Infinity"];
+  for (const expression of expressions) {
+    const { result } = await Runtime.evaluate({ contextId, expression });
+    is(result.unserializableValue, expression, `Evaluating unserializable '${expression}' works`);
+  }
+}
+
+async function testObjectTypes({ Runtime }, contextId) {
+  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" },
+    { expression: "new Map()", type: "object", subtype: "map" },
+    { 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 });
+    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')" });
+  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'" });
+  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()" });
+  is(exceptionDetails.text, "doesNotExists is not defined", "Exception message is passed to the client");
+}
--- a/remote/test/browser/browser_runtime_executionContext.js
+++ b/remote/test/browser/browser_runtime_executionContext.js
@@ -46,16 +46,17 @@ async function testCDP() {
     },
   });
   ok(true, "CDP client has been instantiated");
 
   const firstContext = await testRuntimeEnable(client);
   await testEvaluate(client, firstContext);
   const secondContext = await testNavigate(client, firstContext);
   await testNavigateBack(client, firstContext, secondContext);
+  await testNavigateViaLocation(client, firstContext);
 
   await client.close();
   ok(true, "The client is closed");
 
   BrowserTestUtils.removeTab(gBrowser.selectedTab);
 
   await RemoteAgent.close();
 }
@@ -69,16 +70,23 @@ async function testRuntimeEnable({ Runti
   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 testEvaluate({ Runtime }, previousContext) {
+  const contextId = previousContext.id;
+
+  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 testNavigate({ Runtime, Page }, previousContext) {
   info("Navigate to a new URL");
   const executionContextDestroyed = Runtime.executionContextDestroyed();
   const executionContextCreated = Runtime.executionContextCreated();
 
   const url = "data:text/html;charset=utf-8,test-page";
   const { frameId } = await Page.navigate({ url });
   ok(true, "A new page has been loaded");
@@ -113,9 +121,31 @@ async function testNavigateBack({ Runtim
   const { context } = await executionContextCreated;
   is(context.id, firstContext.id, "The new execution context should be the same than the first one");
   ok(context.auxData.isDefault, "The execution context is the default one");
   is(context.auxData.frameId, firstContext.auxData.frameId, "The execution context frame id is always the same");
 
   const { executionContextId } = await executionContextDestroyed;
   is(executionContextId, previousContext.id, "The destroyed event reports the previous context id");
 
+  const { result } = await Runtime.evaluate({ contextId: context.id, expression: "location.href" });
+  is(result.value, TEST_URI, "Runtime.evaluate works and is against the page we just navigated to");
 }
+
+async function testNavigateViaLocation({ Runtime }, previousContext) {
+  const executionContextDestroyed = Runtime.executionContextDestroyed();
+  const executionContextCreated = Runtime.executionContextCreated();
+
+  const url2 = "data:text/html;charset=utf-8,test-page-2";
+  await Runtime.evaluate({ contextId: previousContext.id, expression: `window.location = '${url2}';` });
+
+  const { executionContextId } = await executionContextDestroyed;
+  is(executionContextId, previousContext.id, "The destroyed event reports the previous context id");
+
+  const { context } = await executionContextCreated;
+  ok(!!context.id, "The execution context has an id");
+  ok(context.auxData.isDefault, "The execution context is the default one");
+  is(context.auxData.frameId, previousContext.auxData.frameId, "The execution context frame id is the same " +
+    "the one returned by Page.navigate");
+
+  isnot(executionContextId, context.id, "The destroyed id is different from the " +
+    "created one");
+}