Bug 1600959 - Implement Runtime.executionContextsCleared r=remote-protocol-reviewers,whimboo,ato
authorMaja Frydrychowicz <mjzffr@gmail.com>
Thu, 19 Dec 2019 19:58:52 +0000
changeset 507917 78569ad3903317268d0f3361db1970f770175a53
parent 507916 e70a4f7271bb823063f28df278d3b8efee143cdc
child 507918 19faafe628bdf0fdbfa2e8b963c8f804c6c42eb9
push id36933
push useraciure@mozilla.com
push dateFri, 20 Dec 2019 04:15:17 +0000
treeherdermozilla-central@c8ce8b91465a [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersremote-protocol-reviewers, whimboo, ato
bugs1600959
milestone73.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 1600959 - Implement Runtime.executionContextsCleared r=remote-protocol-reviewers,whimboo,ato Differential Revision: https://phabricator.services.mozilla.com/D55868
remote/domains/content/Runtime.jsm
remote/test/browser/head.js
remote/test/browser/page/browser_createIsolatedWorld.js
remote/test/browser/runtime/browser_executionContext.js
--- a/remote/domains/content/Runtime.jsm
+++ b/remote/domains/content/Runtime.jsm
@@ -310,17 +310,18 @@ class Runtime extends ContentProcessDoma
       });
     }
 
     return context.id;
   }
 
   /**
    * Helper method to destroy the ExecutionContext of the given id. Also emit
-   * the related `Runtime.executionContextDestroyed` event.
+   * the related `Runtime.executionContextDestroyed` and
+   * `Runtime.executionContextsCleared` events.
    * ContextObserver will call this method with either `id` or `frameId` argument
    * being set.
    *
    * @param {string} name
    *     Event name
    * @param {Object=} options
    * @param {number} id
    *     The execution context id to destroy.
@@ -350,12 +351,13 @@ class Runtime extends ContentProcessDoma
       this.contextsByWindow.get(ctx.windowId).delete(ctx);
       if (this.enabled) {
         this.emit("Runtime.executionContextDestroyed", {
           executionContextId: ctx.id,
         });
       }
       if (this.contextsByWindow.get(ctx.windowId).size == 0) {
         this.contextsByWindow.delete(ctx.windowId);
+        this.emit("Runtime.executionContextsCleared");
       }
     }
   }
 }
--- a/remote/test/browser/head.js
+++ b/remote/test/browser/head.js
@@ -256,8 +256,102 @@ async function createFile(contents, opti
     registerCleanupFunction(async () => {
       await file.close();
       await OS.File.remove(path, { ignoreAbsent: true });
     });
   }
 
   return { file, path };
 }
+
+class RecordEvents {
+  /**
+   * A timeline of events chosen by calls to `addRecorder`.
+   * Call `configure`` for each client event you want to record.
+   * Then `await record(someTimeout)` to record a timeline that you
+   * can make assertions about.
+   *
+   * const history = new RecordEvents(expectedNumberOfEvents);
+   *
+   * history.addRecorder({
+   *  event: Runtime.executionContextDestroyed,
+   *  eventName: "Runtime.executionContextDestroyed",
+   *  messageFn: payload => {
+   *    return `Received Runtime.executionContextDestroyed for id ${payload.executionContextId}`;
+   *  },
+   * });
+   *
+   *
+   * @param {number} total
+   *     Number of expected events. Stop recording when this number is exceeded.
+   *
+   */
+  constructor(total) {
+    this.events = [];
+    this.promises = new Set();
+    this.subscriptions = new Set();
+    this.total = total;
+  }
+
+  /**
+   * Configure an event to be recorded and logged.
+   * The recording stops once we accumulate more than the expected
+   * total of all configured events.
+   *
+   * @param {Object} options
+   * @param {CDPEvent} options.event
+   *     https://github.com/cyrus-and/chrome-remote-interface#clientdomaineventcallback
+   * @param {string} options.eventName
+   *     Name to use for reporting.
+   * @param {function(payload):string=} options.messageFn
+   */
+  addRecorder(options = {}) {
+    const {
+      event,
+      eventName,
+      messageFn = () => `Received ${eventName}`,
+    } = options;
+    const promise = new Promise(resolve => {
+      const unsubscribe = event(payload => {
+        info(messageFn(payload));
+        this.events.push({ eventName, payload });
+        if (this.events.length > this.total) {
+          this.subscriptions.delete(unsubscribe);
+          unsubscribe();
+          resolve(this.events);
+        }
+      });
+      this.subscriptions.add(unsubscribe);
+    });
+    this.promises.add(promise);
+  }
+
+  /**
+   * Record events until we hit the timeout or the expected total is exceeded.
+   *
+   * @param {number=} timeout
+   *     milliseconds
+   *
+   * @return {Array<{ eventName, payload }>} Recorded events
+   */
+  async record(timeout = 1000) {
+    await Promise.race([Promise.all(this.promises), timeoutPromise(timeout)]);
+    for (const unsubscribe of this.subscriptions) {
+      unsubscribe();
+    }
+    return this.events;
+  }
+
+  /**
+   * Find first occurrence of the given event.
+   *
+   * @param {string} eventName
+   *
+   * @return {object} The event payload, if any.
+   */
+  findEvent(eventName) {
+    const event = this.events.find(el => el.eventName == eventName);
+    if (event) {
+      return event.payload;
+    }
+    return {};
+  }
+}
--- a/remote/test/browser/page/browser_createIsolatedWorld.js
+++ b/remote/test/browser/page/browser_createIsolatedWorld.js
@@ -1,91 +1,105 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 "use strict";
 
 // Test Page.createIsolatedWorld
 
 const DOC = toDataURL("default-test-page");
-const WORLD_NAME = "testWorld";
+const WORLD_NAME_1 = "testWorld1";
+const WORLD_NAME_2 = "testWorld2";
+const WORLD_NAME_3 = "testWorld3";
+const DESTROYED = "Runtime.executionContextDestroyed";
+const CREATED = "Runtime.executionContextCreated";
+const CLEARED = "Runtime.executionContextsCleared";
 
 add_task(async function createContextNoRuntimeDomain({ Page }) {
   const { frameId } = await Page.navigate({ url: DOC });
   const { executionContextId: isolatedId } = await Page.createIsolatedWorld({
     frameId,
-    worldName: WORLD_NAME,
+    worldName: WORLD_NAME_1,
     grantUniversalAccess: true,
   });
   ok(typeof isolatedId == "number", "Page.createIsolatedWorld returns an id");
 });
 
 add_task(async function createContextRuntimeDisabled({ Runtime, Page }) {
-  const { promises, resolutions } = recordEvents(Runtime, 0);
+  const history = recordEvents(Runtime, 0);
   await Runtime.disable();
   info("Runtime notifications are disabled");
   const { frameId } = await Page.navigate({ url: DOC });
   await Page.createIsolatedWorld({
     frameId,
-    worldName: WORLD_NAME,
+    worldName: WORLD_NAME_2,
     grantUniversalAccess: true,
   });
-  await assertEventOrder(promises, resolutions, []);
+  await assertEventOrder({ history, expectedEvents: [] });
 });
 
 add_task(async function contextCreatedAfterNavigation({ Page, Runtime }) {
-  const { promises, resolutions } = recordEvents(Runtime, 4);
+  const history = recordEvents(Runtime, 4);
   await Runtime.enable();
   info("Runtime notifications are enabled");
   info("Navigating...");
   const { frameId } = await Page.navigate({ url: DOC });
   const { executionContextId: isolatedId } = await Page.createIsolatedWorld({
     frameId,
-    worldName: WORLD_NAME,
+    worldName: WORLD_NAME_3,
     grantUniversalAccess: true,
   });
-  await assertEventOrder(promises, resolutions, [
-    "Runtime.executionContextCreated", // default, about:blank
-    "Runtime.executionContextDestroyed", // default, about:blank
-    "Runtime.executionContextCreated", // default, DOC
-    "Runtime.executionContextCreated", // isolated, DOC
-  ]);
-  const defaultContext = resolutions[2].payload.context;
-  const isolatedContext = resolutions[3].payload.context;
+  await assertEventOrder({
+    history,
+    expectedEvents: [
+      CREATED, // default, about:blank
+      DESTROYED, // default, about:blank
+      CREATED, // default, DOC
+      CREATED, // isolated, DOC
+    ],
+    timeout: 2000,
+  });
+  const defaultContext = history.events[2].payload.context;
+  const isolatedContext = history.events[3].payload.context;
   is(defaultContext.auxData.isDefault, true, "Default context is default");
   is(
     defaultContext.auxData.type,
     "default",
     "Default context has type 'default'"
   );
   is(defaultContext.origin, DOC, "Default context has expected origin");
-  checkIsolated(isolatedContext, isolatedId);
+  checkIsolated(isolatedContext, isolatedId, WORLD_NAME_3);
   compareContexts(isolatedContext, defaultContext);
 });
 
 add_task(async function contextDestroyedAfterNavigation({ Page, Runtime }) {
   const { isolatedId, defaultContext } = await setupContexts(Page, Runtime);
   is(defaultContext.auxData.isDefault, true, "Default context is default");
-  const { promises, resolutions } = recordEvents(Runtime, 3);
+  const history = recordEvents(Runtime, 4, true);
   info("Navigating...");
   await Page.navigate({ url: DOC });
 
-  await assertEventOrder(promises, resolutions, [
-    "Runtime.executionContextDestroyed", // default, about:blank
-    "Runtime.executionContextDestroyed", // isolated, about:blank
-    "Runtime.executionContextCreated", // default, DOC
-  ]);
+  await assertEventOrder({
+    history,
+    expectedEvents: [
+      DESTROYED, // default, about:blank
+      DESTROYED, // isolated, about:blank
+      CLEARED,
+      CREATED, // default, DOC
+    ],
+    timeout: 2000,
+  });
   const destroyed = [
-    resolutions[0].payload.executionContextId,
-    resolutions[1].payload.executionContextId,
+    history.events[0].payload.executionContextId,
+    history.events[1].payload.executionContextId,
   ];
   ok(destroyed.includes(isolatedId), "Isolated context destroyed");
   ok(destroyed.includes(defaultContext.id), "Default context destroyed");
 
-  const newContext = resolutions[2].payload.context;
+  const newContext = history.events[3].payload.context;
   is(newContext.auxData.isDefault, true, "The new context is a default one");
   ok(!!newContext.id, "The new context has an id");
   ok(
     ![isolatedId, defaultContext.id].includes(newContext.id),
     "The new context has a new id"
   );
 });
 
@@ -144,21 +158,21 @@ add_task(async function contextEvaluatio
   is(result1.value, null, "Default context sees content changes to global");
   todo_isnot(
     result2.value,
     null,
     "Isolated context is not affected by changes to global, Bug 1601421"
   );
 });
 
-function checkIsolated(context, expectedId) {
+function checkIsolated(context, expectedId, expectedName) {
   ok(!!context.id, "Isolated context has an id");
   ok(!!context.origin, "Isolated context has an origin");
   ok(!!context.auxData.frameId, "Isolated context has a frameId");
-  is(context.name, WORLD_NAME, "Isolated context is named as requested");
+  is(context.name, expectedName, "Isolated context is named as requested");
   is(
     expectedId,
     context.id,
     "createIsolatedWorld returns id of isolated context"
   );
   is(context.auxData.isDefault, false, "Isolated context is not default");
   is(context.auxData.type, "isolated", "Isolated context has type 'isolated'");
 }
@@ -189,59 +203,59 @@ function compareContexts(isolatedContext
 async function setupContexts(Page, Runtime) {
   const defaultContextCreated = Runtime.executionContextCreated();
   await Runtime.enable();
   info("Runtime notifications are enabled");
   const defaultContext = (await defaultContextCreated).context;
   const isolatedContextCreated = Runtime.executionContextCreated();
   const { executionContextId: isolatedId } = await Page.createIsolatedWorld({
     frameId: defaultContext.auxData.frameId,
-    worldName: WORLD_NAME,
+    worldName: WORLD_NAME_1,
     grantUniversalAccess: true,
   });
   const isolatedContext = (await isolatedContextCreated).context;
   info("Isolated world created");
-  checkIsolated(isolatedContext, isolatedId);
+  checkIsolated(isolatedContext, isolatedId, WORLD_NAME_1);
   compareContexts(isolatedContext, defaultContext);
   return { isolatedContext, defaultContext, isolatedId };
 }
 
-function recordEvents(Runtime, total) {
-  const resolutions = [];
-  const promises = new Set();
-
-  recordPromises(
-    Runtime.executionContextDestroyed,
-    "Runtime.executionContextDestroyed"
-  );
-  recordPromises(
-    Runtime.executionContextCreated,
-    "Runtime.executionContextCreated",
-    "id"
-  );
+function recordEvents(Runtime, total, cleared = false) {
+  const history = new RecordEvents(total);
 
-  function recordPromises(event, eventName, prop) {
-    const promise = new Promise(resolve => {
-      const unsubscribe = event(payload => {
-        const propSuffix = prop ? ` for ${prop} ${payload.context[prop]}` : "";
-        const message = `Received ${eventName} ${propSuffix}`;
-        info(message);
-        resolutions.push({ eventName, payload });
+  history.addRecorder({
+    event: Runtime.executionContextDestroyed,
+    eventName: DESTROYED,
+    messageFn: payload => {
+      return `Received ${DESTROYED} for id ${payload.executionContextId}`;
+    },
+  });
+  history.addRecorder({
+    event: Runtime.executionContextCreated,
+    eventName: CREATED,
+    messageFn: payload => {
+      return (
+        `Received ${CREATED} for id ${payload.context.id}` +
+        ` type: ${payload.context.auxData.type}` +
+        ` name: ${payload.context.name}` +
+        ` origin: ${payload.context.origin}`
+      );
+    },
+  });
+  if (cleared) {
+    history.addRecorder({
+      event: Runtime.executionContextsCleared,
+      eventName: CLEARED,
+    });
+  }
 
-        if (resolutions.length > total) {
-          unsubscribe();
-          resolve();
-        }
-      });
-    });
-    promises.add(promise);
-  }
-  return { promises, resolutions };
+  return history;
 }
 
-async function assertEventOrder(promises, resolutions, expectedResolutions) {
-  await Promise.race([Promise.all(promises), timeoutPromise(1000)]);
+async function assertEventOrder(options = {}) {
+  const { history, expectedEvents, timeout = 1000 } = options;
+  const events = await history.record(timeout);
   Assert.deepEqual(
-    resolutions.map(item => item.eventName),
-    expectedResolutions,
+    events.map(item => item.eventName),
+    expectedEvents,
     "Received Runtime context events in expected order"
   );
 }
--- a/remote/test/browser/runtime/browser_executionContext.js
+++ b/remote/test/browser/runtime/browser_executionContext.js
@@ -1,16 +1,19 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 "use strict";
 
 // Test the Runtime execution context events
 
 const TEST_DOC = toDataURL("default-test-page");
+const DESTROYED = "Runtime.executionContextDestroyed";
+const CREATED = "Runtime.executionContextCreated";
+const CLEARED = "Runtime.executionContextsCleared";
 
 add_task(async function(client) {
   await loadURL(TEST_DOC);
 
   const context1 = await testRuntimeEnable(client);
   await testEvaluate(client, context1);
   const context2 = await testNavigate(client, context1);
   const context3 = await testNavigateBack(client, context1, context2);
@@ -34,26 +37,21 @@ add_task(async function testRuntimeNotEn
   const result = await Promise.race([created, destroyed, timeout]);
   is(result, sentinel, "No Runtime events emitted while Runtime is disabled");
 });
 
 async function testRuntimeEnable({ Runtime }) {
   const contextCreated = Runtime.executionContextCreated();
   // Enable watching for new execution context
   await Runtime.enable();
-  info("Runtime domain has been enabled");
+  info("Runtime notifications enabled");
 
   // Calling Runtime.enable will emit executionContextCreated for the existing contexts
   const { context } = await contextCreated;
-  ok(!!context.id, "The execution context has an id");
-  ok(!!context.origin, "The execution context has an origin");
-  is(context.name, "", "The default execution context is named ''");
-  ok(context.auxData.isDefault, "The execution context is the default one");
-  ok(!!context.auxData.frameId, "The execution context has a frame id set");
-  is(context.auxData.type, "default", "Execution context has 'default' type");
+  checkDefaultContext(context);
 
   return context;
 }
 
 async function testEvaluate({ Runtime }, previousContext) {
   const contextId = previousContext.id;
 
   const { result } = await Runtime.evaluate({
@@ -65,73 +63,68 @@ async function testEvaluate({ Runtime },
     TEST_DOC,
     "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 history = recordContextEvents(Runtime, 3);
 
   const { frameId } = await Page.navigate({ url: toDataURL("test-page") });
   info("A new page has been loaded");
   is(
     frameId,
     previousContext.auxData.frameId,
     "Page.navigate returns the same frameId as executionContextCreated"
   );
+  await assertEventOrder({ history });
 
-  const { executionContextId } = await executionContextDestroyed;
+  const { executionContextId } = history.findEvent(DESTROYED);
   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");
+  const { context } = history.findEvent(CREATED);
+  checkDefaultContext(context);
   isnot(
     previousContext.id,
     context.id,
     "The new execution context has a different id"
   );
-  ok(context.auxData.isDefault, "The execution context is the default one");
   is(
     context.auxData.frameId,
     frameId,
     "The execution context frame id is the same " +
       "than the one returned by Page.navigate"
   );
-  is(context.auxData.type, "default", "Execution context has 'default' type");
-  ok(!!context.origin, "The execution context has an origin");
-  is(context.name, "", "The default execution context is named ''");
 
   isnot(
     executionContextId,
     context.id,
     "The destroyed id is different from the created one"
   );
 
   return context;
 }
 
 // Navigates back to the previous page.
 // This should resurrect the original document from the BF Cache and recreate the
 // context for it
 async function testNavigateBack({ Runtime }, firstContext, previousContext) {
   info("Navigate back to the previous document");
 
-  const executionContextDestroyed = Runtime.executionContextDestroyed();
-  const executionContextCreated = Runtime.executionContextCreated();
+  const history = recordContextEvents(Runtime, 3);
+  gBrowser.selectedBrowser.goBack();
+  await assertEventOrder({ history });
 
-  gBrowser.selectedBrowser.goBack();
-
-  const { context } = await executionContextCreated;
+  const { context } = history.findEvent(CREATED);
   ok(!!context.origin, "The execution context has an origin");
   is(
     context.origin,
     firstContext.origin,
     "The new execution context should have the same origin as the first."
   );
   isnot(
     context.id,
@@ -142,17 +135,17 @@ async function testNavigateBack({ Runtim
   is(
     context.auxData.frameId,
     firstContext.auxData.frameId,
     "The execution context frame id is always the same"
   );
   is(context.auxData.type, "default", "Execution context has 'default' type");
   is(context.name, "", "The default execution context is named ''");
 
-  const { executionContextId } = await executionContextDestroyed;
+  const { executionContextId } = history.findEvent(DESTROYED);
   is(
     executionContextId,
     previousContext.id,
     "The destroyed event reports the previous context id"
   );
 
   const { result } = await Runtime.evaluate({
     contextId: context.id,
@@ -165,79 +158,120 @@ async function testNavigateBack({ Runtim
   );
 
   return context;
 }
 
 async function testNavigateViaLocation({ Runtime }, previousContext) {
   info("Navigate via window.location and Runtime.evaluate");
 
-  const executionContextDestroyed = Runtime.executionContextDestroyed();
-  const executionContextCreated = Runtime.executionContextCreated();
+  const history = recordContextEvents(Runtime, 3);
 
   const url2 = toDataURL("test-page-2");
   await Runtime.evaluate({
     contextId: previousContext.id,
     expression: `window.location = '${url2}';`,
   });
-
-  const { executionContextId } = await executionContextDestroyed;
+  await assertEventOrder({ history });
+  const { executionContextId } = history.findEvent(DESTROYED);
   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");
+  const { context } = history.findEvent(CREATED);
+  checkDefaultContext(context);
   is(
     context.auxData.frameId,
     previousContext.auxData.frameId,
     "The execution context frame id is the same " +
       "the one returned by Page.navigate"
   );
-  is(context.auxData.type, "default", "Execution context has 'default' type");
-  ok(!!context.origin, "The execution context has an origin");
-  is(context.name, "", "The default execution context is named ''");
 
   isnot(
     executionContextId,
     context.id,
     "The destroyed id is different from the created one"
   );
 
   return context;
 }
 
 async function testReload({ Runtime, Page }, previousContext) {
   info("Test reloading via Page.reload");
-
-  const executionContextDestroyed = Runtime.executionContextDestroyed();
-  const executionContextCreated = Runtime.executionContextCreated();
+  const history = recordContextEvents(Runtime, 3);
+  await Page.reload();
+  await assertEventOrder({ history });
 
-  await Page.reload();
-
-  const { executionContextId } = await executionContextDestroyed;
+  const { executionContextId } = history.findEvent(DESTROYED);
   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");
+  const { context } = history.findEvent(CREATED);
+  checkDefaultContext(context);
   is(
     context.auxData.frameId,
     previousContext.auxData.frameId,
     "The execution context frame id is the same one"
   );
-  is(context.auxData.type, "default", "Execution context has 'default' type");
-  ok(!!context.origin, "The execution context has an origin");
-  is(context.name, "", "The default execution context is named ''");
 
   isnot(
     executionContextId,
     context.id,
     "The destroyed id is different from the created one"
   );
 }
+
+function recordContextEvents(Runtime, total) {
+  const history = new RecordEvents(total);
+
+  history.addRecorder({
+    event: Runtime.executionContextDestroyed,
+    eventName: DESTROYED,
+    messageFn: payload => {
+      return `Received ${DESTROYED} for id ${payload.executionContextId}`;
+    },
+  });
+  history.addRecorder({
+    event: Runtime.executionContextCreated,
+    eventName: CREATED,
+    messageFn: payload => {
+      return (
+        `Received ${CREATED} for id ${payload.context.id}` +
+        ` type: ${payload.context.auxData.type}` +
+        ` name: ${payload.context.name}` +
+        ` origin: ${payload.context.origin}`
+      );
+    },
+  });
+  history.addRecorder({
+    event: Runtime.executionContextsCleared,
+    eventName: CLEARED,
+  });
+
+  return history;
+}
+
+async function assertEventOrder(options = {}) {
+  const {
+    history,
+    expectedevents = [DESTROYED, CLEARED, CREATED],
+    timeout = 2000,
+  } = options;
+  const events = await history.record(timeout);
+  Assert.deepEqual(
+    events.map(item => item.eventName),
+    expectedevents,
+    "Received Runtime context events in expected order"
+  );
+}
+
+function checkDefaultContext(context) {
+  ok(!!context.id, "The execution context has an id");
+  ok(context.auxData.isDefault, "The execution context is the default one");
+  is(context.auxData.type, "default", "Execution context has 'default' type");
+  ok(!!context.origin, "The execution context has an origin");
+  is(context.name, "", "The default execution context is named ''");
+}