Bug 1060138: Fix SDK context-menu API to work in e10s. r=zombie
authorDave Townsend <dtownsend@oxymoronical.com>
Mon, 17 Nov 2014 14:08:07 -0800
changeset 216073 872f2f8a6c0d9517fab6cfd3dae2f44a49cfea4e
parent 216072 cc48ff0ed5092319d1953dc15fc595c7ea0cbbbf
child 216074 a0149f5555a58ec4d272ffa2cc3ebaa8f1046652
push id10003
push userdtownsend@mozilla.com
push dateMon, 17 Nov 2014 22:08:40 +0000
treeherderfx-team@872f2f8a6c0d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerszombie
bugs1060138
milestone36.0a1
Bug 1060138: Fix SDK context-menu API to work in e10s. r=zombie
addon-sdk/moz.build
addon-sdk/source/lib/framescript/FrameScriptManager.jsm
addon-sdk/source/lib/framescript/contextmenu-events.js
addon-sdk/source/lib/sdk/content/context-menu.js
addon-sdk/source/lib/sdk/context-menu.js
addon-sdk/source/test/test-context-menu.js
--- a/addon-sdk/moz.build
+++ b/addon-sdk/moz.build
@@ -166,16 +166,17 @@ EXTRA_JS_MODULES.commonjs.diffpatcher.te
     'source/lib/diffpatcher/test/common.js',
     'source/lib/diffpatcher/test/diff.js',
     'source/lib/diffpatcher/test/index.js',
     'source/lib/diffpatcher/test/patch.js',
     'source/lib/diffpatcher/test/tap.js',
 ]
 
 EXTRA_JS_MODULES.commonjs.framescript += [
+    'source/lib/framescript/contextmenu-events.js',
     'source/lib/framescript/FrameScriptManager.jsm',
     'source/lib/framescript/LoaderHelper.jsm',
     'source/lib/framescript/tab-events.js',
 ]
 
 EXTRA_JS_MODULES.commonjs.method += [
     'source/lib/method/core.js',
 ]
@@ -230,16 +231,17 @@ EXTRA_JS_MODULES.commonjs.sdk.browser +=
 EXTRA_JS_MODULES.commonjs.sdk.console += [
     'source/lib/sdk/console/plain-text.js',
     'source/lib/sdk/console/traceback.js',
 ]
 
 EXTRA_JS_MODULES.commonjs.sdk.content += [
     'source/lib/sdk/content/content-worker.js',
     'source/lib/sdk/content/content.js',
+    'source/lib/sdk/content/context-menu.js',
     'source/lib/sdk/content/events.js',
     'source/lib/sdk/content/loader.js',
     'source/lib/sdk/content/mod.js',
     'source/lib/sdk/content/sandbox.js',
     'source/lib/sdk/content/thumbnail.js',
     'source/lib/sdk/content/utils.js',
     'source/lib/sdk/content/worker-child.js',
     'source/lib/sdk/content/worker-parent.js',
--- a/addon-sdk/source/lib/framescript/FrameScriptManager.jsm
+++ b/addon-sdk/source/lib/framescript/FrameScriptManager.jsm
@@ -10,16 +10,26 @@ const globalMM = Components.classes["@mo
 // Since this JSM will be loaded using require(), PATH will be
 // overridden while running tests, just like any other module.
 const PATH = __URI__.replace('FrameScriptManager.jsm', '');
 
 // ensure frame scripts are loaded only once
 let loadedTabEvents = false;
 
 function enableTabEvents() {
-  if (loadedTabEvents) 
+  if (loadedTabEvents)
     return;
 
   loadedTabEvents = true;
   globalMM.loadFrameScript(PATH + 'tab-events.js', true);
 }
 
-const EXPORTED_SYMBOLS = ['enableTabEvents'];
+let loadedCMEvents = false;
+
+function enableCMEvents() {
+  if (loadedCMEvents)
+    return;
+
+  loadedCMEvents = true;
+  globalMM.loadFrameScript(PATH + 'contextmenu-events.js', true);
+}
+
+const EXPORTED_SYMBOLS = ['enableTabEvents', 'enableCMEvents'];
new file mode 100644
--- /dev/null
+++ b/addon-sdk/source/lib/framescript/contextmenu-events.js
@@ -0,0 +1,63 @@
+/* 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";
+
+const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+
+const { Services } = Cu.import("resource://gre/modules/Services.jsm", {});
+
+// Holds remote items for this frame.
+let keepAlive = new Map();
+
+// Called to create remote proxies for items. If they already exist we destroy
+// and recreate. This cna happen if the item changes in some way or in odd
+// timing cases where the frame script is create around the same time as the
+// item is created in the main process
+addMessageListener('sdk/contextmenu/createitems', ({ data: { items, addon }}) => {
+  let { loader } = Cu.import(addon.paths[''] + 'framescript/LoaderHelper.jsm', {});
+
+  for (let itemoptions of items) {
+    let { RemoteItem } = loader(addon).require('sdk/content/context-menu');
+    let item = new RemoteItem(itemoptions, this);
+
+    let oldItem = keepAlive.get(item.id);
+    if (oldItem) {
+      oldItem.destroy();
+    }
+
+    keepAlive.set(item.id, item);
+  }
+});
+
+addMessageListener('sdk/contextmenu/destroyitems', ({ data: { items }}) => {
+  for (let id of items) {
+    let item = keepAlive.get(id);
+    item.destroy();
+    keepAlive.delete(id);
+  }
+});
+
+sendAsyncMessage('sdk/contextmenu/requestitems');
+
+Services.obs.addObserver(function(subject, topic, data) {
+  // Many frame scripts run in the same process, check that the context menu
+  // node is in this frame
+  let { event: { target: popupNode }, addonInfo } = subject.wrappedJSObject;
+  if (popupNode.ownerDocument.defaultView.top != content)
+    return;
+
+  for (let item of keepAlive.values()) {
+    item.getContextState(popupNode, addonInfo);
+  }
+}, "content-contextmenu", false);
+
+addMessageListener('sdk/contextmenu/activateitems', ({ data: { items, data }, objects: { popupNode }}) => {
+  for (let id of items) {
+    let item = keepAlive.get(id);
+    if (!item)
+      continue;
+
+    item.activate(popupNode, data);
+  }
+});
new file mode 100644
--- /dev/null
+++ b/addon-sdk/source/lib/sdk/content/context-menu.js
@@ -0,0 +1,354 @@
+/* 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";
+
+const { Class } = require("../core/heritage");
+const self = require("../self");
+const { WorkerChild } = require("./worker-child");
+const { getInnerId } = require("../window/utils");
+const { Ci } = require("chrome");
+const { Services } = require("resource://gre/modules/Services.jsm");
+
+// These functions are roughly copied from sdk/selection which doesn't work
+// in the content process
+function getElementWithSelection(window) {
+  let element = Services.focus.getFocusedElementForWindow(window, false, {});
+  if (!element)
+    return null;
+
+  try {
+    // Accessing selectionStart and selectionEnd on e.g. a button
+    // results in an exception thrown as per the HTML5 spec.  See
+    // http://www.whatwg.org/specs/web-apps/current-work/multipage/association-of-controls-and-forms.html#textFieldSelection
+
+    let { value, selectionStart, selectionEnd } = element;
+
+    let hasSelection = typeof value === "string" &&
+                      !isNaN(selectionStart) &&
+                      !isNaN(selectionEnd) &&
+                      selectionStart !== selectionEnd;
+
+    return hasSelection ? element : null;
+  }
+  catch (err) {
+    console.exception(err);
+    return null;
+  }
+}
+
+function safeGetRange(selection, rangeNumber) {
+  try {
+    let { rangeCount } = selection;
+    let range = null;
+
+    for (let rangeNumber = 0; rangeNumber < rangeCount; rangeNumber++ ) {
+      range = selection.getRangeAt(rangeNumber);
+
+      if (range && range.toString())
+        break;
+
+      range = null;
+    }
+
+    return range;
+  }
+  catch (e) {
+    return null;
+  }
+}
+
+function getSelection(window) {
+  let selection = window.getSelection();
+  let range = safeGetRange(selection);
+  if (range)
+    return range.toString();
+
+  let node = getElementWithSelection(window);
+  if (!node)
+    return null;
+
+  return node.value.substring(node.selectionStart, node.selectionEnd);
+}
+
+//These are used by PageContext.isCurrent below. If the popupNode or any of
+//its ancestors is one of these, Firefox uses a tailored context menu, and so
+//the page context doesn't apply.
+const NON_PAGE_CONTEXT_ELTS = [
+  Ci.nsIDOMHTMLAnchorElement,
+  Ci.nsIDOMHTMLAppletElement,
+  Ci.nsIDOMHTMLAreaElement,
+  Ci.nsIDOMHTMLButtonElement,
+  Ci.nsIDOMHTMLCanvasElement,
+  Ci.nsIDOMHTMLEmbedElement,
+  Ci.nsIDOMHTMLImageElement,
+  Ci.nsIDOMHTMLInputElement,
+  Ci.nsIDOMHTMLMapElement,
+  Ci.nsIDOMHTMLMediaElement,
+  Ci.nsIDOMHTMLMenuElement,
+  Ci.nsIDOMHTMLObjectElement,
+  Ci.nsIDOMHTMLOptionElement,
+  Ci.nsIDOMHTMLSelectElement,
+  Ci.nsIDOMHTMLTextAreaElement,
+];
+
+// List all editable types of inputs.  Or is it better to have a list
+// of non-editable inputs?
+let editableInputs = {
+  email: true,
+  number: true,
+  password: true,
+  search: true,
+  tel: true,
+  text: true,
+  textarea: true,
+  url: true
+};
+
+let CONTEXTS = {};
+
+let Context = Class({
+  initialize: function(id) {
+    this.id = id;
+  },
+
+  adjustPopupNode: function adjustPopupNode(popupNode) {
+    return popupNode;
+  },
+
+  // Gets state to pass through to the parent process for the node the user
+  // clicked on
+  getState: function(popupNode) {
+    return false;
+  }
+});
+
+// Matches when the context-clicked node doesn't have any of
+// NON_PAGE_CONTEXT_ELTS in its ancestors
+CONTEXTS.PageContext = Class({
+  extends: Context,
+
+  getState: function(popupNode) {
+    // If there is a selection in the window then this context does not match
+    if (!popupNode.ownerDocument.defaultView.getSelection().isCollapsed)
+      return false;
+
+    // If the clicked node or any of its ancestors is one of the blacklisted
+    // NON_PAGE_CONTEXT_ELTS then this context does not match
+    while (!(popupNode instanceof Ci.nsIDOMDocument)) {
+      if (NON_PAGE_CONTEXT_ELTS.some(function(type) popupNode instanceof type))
+        return false;
+
+      popupNode = popupNode.parentNode;
+    }
+
+    return true;
+  }
+});
+
+// Matches when there is an active selection in the window
+CONTEXTS.SelectionContext = Class({
+  extends: Context,
+
+  getState: function(popupNode) {
+    if (!popupNode.ownerDocument.defaultView.getSelection().isCollapsed)
+      return true;
+
+    try {
+      // The node may be a text box which has selectionStart and selectionEnd
+      // properties. If not this will throw.
+      let { selectionStart, selectionEnd } = popupNode;
+      return !isNaN(selectionStart) && !isNaN(selectionEnd) &&
+             selectionStart !== selectionEnd;
+    }
+    catch (e) {
+      return false;
+    }
+  }
+});
+
+// Matches when the context-clicked node or any of its ancestors matches the
+// selector given
+CONTEXTS.SelectorContext = Class({
+  extends: Context,
+
+  initialize: function initialize(id, selector) {
+    Context.prototype.initialize.call(this, id);
+    this.selector = selector;
+  },
+
+  adjustPopupNode: function adjustPopupNode(popupNode) {
+    let selector = this.selector;
+
+    while (!(popupNode instanceof Ci.nsIDOMDocument)) {
+      if (popupNode.mozMatchesSelector(selector))
+        return popupNode;
+
+      popupNode = popupNode.parentNode;
+    }
+
+    return null;
+  },
+
+  getState: function(popupNode) {
+    return !!this.adjustPopupNode(popupNode);
+  }
+});
+
+// Matches when the page url matches any of the patterns given
+CONTEXTS.URLContext = Class({
+  extends: Context,
+
+  getState: function(popupNode) {
+    return popupNode.ownerDocument.URL;
+  }
+});
+
+// Matches when the user-supplied predicate returns true
+CONTEXTS.PredicateContext = Class({
+  extends: Context,
+
+  getState: function(node) {
+    let window = node.ownerDocument.defaultView;
+    let data = {};
+
+    data.documentType = node.ownerDocument.contentType;
+
+    data.documentURL = node.ownerDocument.location.href;
+    data.targetName = node.nodeName.toLowerCase();
+    data.targetID = node.id || null ;
+
+    if ((data.targetName === 'input' && editableInputs[node.type]) ||
+        data.targetName === 'textarea') {
+      data.isEditable = !node.readOnly && !node.disabled;
+    }
+    else {
+      data.isEditable = node.isContentEditable;
+    }
+
+    data.selectionText = getSelection(window, "TEXT");
+
+    data.srcURL = node.src || null;
+    data.value = node.value || null;
+
+    while (!data.linkURL && node) {
+      data.linkURL = node.href || null;
+      node = node.parentNode;
+    }
+
+    return data;
+  },
+});
+
+function instantiateContext({ id, type, args }) {
+  if (!(type in CONTEXTS)) {
+    console.error("Attempt to use unknown context " + type);
+    return;
+  }
+  return new CONTEXTS[type](id, ...args);
+}
+
+let ContextWorker = Class({
+  implements: [ WorkerChild ],
+
+  // Calls the context workers context listeners and returns the first result
+  // that is either a string or a value that evaluates to true. If all of the
+  // listeners returned false then returns false. If there are no listeners,
+  // returns true (show the menu item by default).
+  getMatchedContext: function getCurrentContexts(popupNode) {
+    let results = this.sandbox.emitSync("context", popupNode);
+    if (!results.length)
+      return true;
+    return results.reduce((val, result) => val || result);
+  },
+
+  // Emits a click event in the worker's port. popupNode is the node that was
+  // context-clicked, and clickedItemData is the data of the item that was
+  // clicked.
+  fireClick: function fireClick(popupNode, clickedItemData) {
+    this.sandbox.emitSync("click", popupNode, clickedItemData);
+  }
+});
+
+// Gets the item's content script worker for a window, creating one if necessary
+// Once created it will be automatically destroyed when the window unloads.
+// If there is not content scripts for the item then null will be returned.
+function getItemWorkerForWindow(item, window) {
+  if (!item.contentScript && !item.contentScriptFile)
+    return null;
+
+  let id = getInnerId(window);
+  let worker = item.workerMap.get(id);
+
+  if (worker)
+    return worker;
+
+  worker = ContextWorker({
+    id: item.id,
+    window: id,
+    manager: item.manager,
+    contentScript: item.contentScript,
+    contentScriptFile: item.contentScriptFile,
+    onDetach: function() {
+      item.workerMap.delete(id);
+    }
+  });
+
+  item.workerMap.set(id, worker);
+
+  return worker;
+}
+
+// A very simple remote proxy for every item. It's job is to provide data for
+// the main process to use to determine visibility state and to call into
+// content scripts when clicked.
+let RemoteItem = Class({
+  initialize: function(options, manager) {
+    this.id = options.id;
+    this.contexts = [instantiateContext(c) for (c of options.contexts)];
+    this.contentScript = options.contentScript;
+    this.contentScriptFile = options.contentScriptFile;
+
+    this.manager = manager;
+
+    this.workerMap = new Map();
+  },
+
+  destroy: function() {
+    for (let worker of this.workerMap.values()) {
+      worker.destroy();
+    }
+  },
+
+  activate: function(popupNode, data) {
+    let worker = getItemWorkerForWindow(this, popupNode.ownerDocument.defaultView);
+    if (!worker)
+      return;
+
+    for (let context of this.contexts)
+      popupNode = context.adjustPopupNode(popupNode);
+
+    worker.fireClick(popupNode, data);
+  },
+
+  // Fills addonInfo with state data to send through to the main process
+  getContextState: function(popupNode, addonInfo) {
+    if (!(self.id in addonInfo))
+      addonInfo[self.id] = {};
+
+    let worker = getItemWorkerForWindow(this, popupNode.ownerDocument.defaultView);
+    let contextStates = {};
+    for (let context of this.contexts)
+      contextStates[context.id] = context.getState(popupNode);
+
+    addonInfo[self.id][this.id] = {
+      // It isn't ideal to create a PageContext for every item but there isn't
+      // a good shared place to do it.
+      pageContext: (new CONTEXTS.PageContext()).getState(popupNode),
+      contextStates,
+      hasWorker: !!worker,
+      workerContext: worker ? worker.getMatchedContext(popupNode) : true
+    }
+  }
+});
+exports.RemoteItem = RemoteItem;
--- a/addon-sdk/source/lib/sdk/context-menu.js
+++ b/addon-sdk/source/lib/sdk/context-menu.js
@@ -14,24 +14,30 @@ module.metadata = {
 
 const { Class, mix } = require("./core/heritage");
 const { addCollectionProperty } = require("./util/collection");
 const { ns } = require("./core/namespace");
 const { validateOptions, getTypeOf } = require("./deprecated/api-utils");
 const { URL, isValidURI } = require("./url");
 const { WindowTracker, browserWindowIterator } = require("./deprecated/window-utils");
 const { isBrowser, getInnerId } = require("./window/utils");
-const { Ci } = require("chrome");
+const { Ci, Cc, Cu } = require("chrome");
 const { MatchPattern } = require("./util/match-pattern");
 const { Worker } = require("./content/worker");
 const { EventTarget } = require("./event/target");
 const { emit } = require('./event/core');
 const { when } = require('./system/unload');
-const selection = require('./selection');
 const { contract: loaderContract } = require('./content/loader');
+const { omit } = require('./util/object');
+const self = require('./self')
+
+// null-out cycles in .modules to make @loader/options JSONable
+const ADDON = omit(require('@loader/options'), ['modules', 'globals']);
+
+require('../framescript/FrameScriptManager.jsm').enableCMEvents();
 
 // All user items we add have this class.
 const ITEM_CLASS = "addon-context-menu-item";
 
 // Items in the top-level context menu also have this class.
 const TOPLEVEL_ITEM_CLASS = "addon-context-menu-item-toplevel";
 
 // Items in the overflow submenu also have this class.
@@ -54,229 +60,166 @@ const OVERFLOW_MENU_LABEL = "Add-ons";
 const OVERFLOW_MENU_ACCESSKEY = "A";
 
 // The class of the overflow sub-xul:menu.
 const OVERFLOW_MENU_CLASS = "addon-content-menu-overflow-menu";
 
 // The class of the overflow submenu's xul:menupopup.
 const OVERFLOW_POPUP_CLASS = "addon-content-menu-overflow-popup";
 
-//These are used by PageContext.isCurrent below. If the popupNode or any of
-//its ancestors is one of these, Firefox uses a tailored context menu, and so
-//the page context doesn't apply.
-const NON_PAGE_CONTEXT_ELTS = [
-  Ci.nsIDOMHTMLAnchorElement,
-  Ci.nsIDOMHTMLAppletElement,
-  Ci.nsIDOMHTMLAreaElement,
-  Ci.nsIDOMHTMLButtonElement,
-  Ci.nsIDOMHTMLCanvasElement,
-  Ci.nsIDOMHTMLEmbedElement,
-  Ci.nsIDOMHTMLImageElement,
-  Ci.nsIDOMHTMLInputElement,
-  Ci.nsIDOMHTMLMapElement,
-  Ci.nsIDOMHTMLMediaElement,
-  Ci.nsIDOMHTMLMenuElement,
-  Ci.nsIDOMHTMLObjectElement,
-  Ci.nsIDOMHTMLOptionElement,
-  Ci.nsIDOMHTMLSelectElement,
-  Ci.nsIDOMHTMLTextAreaElement,
-];
-
 // Holds private properties for API objects
 let internal = ns();
 
+function uuid() {
+  return require('./util/uuid').uuid().toString();
+}
+
 function getScheme(spec) {
   try {
     return URL(spec).scheme;
   }
   catch(e) {
     return null;
   }
 }
 
+let MessageManager = Cc["@mozilla.org/globalmessagemanager;1"].
+                     getService(Ci.nsIMessageBroadcaster);
+
 let Context = Class({
+  initialize: function() {
+    internal(this).id = uuid();
+  },
+
   // Returns the node that made this context current
   adjustPopupNode: function adjustPopupNode(popupNode) {
     return popupNode;
   },
 
   // Returns whether this context is current for the current node
-  isCurrent: function isCurrent(popupNode) {
-    return false;
+  isCurrent: function isCurrent(state) {
+    return state;
   }
 });
 
 // Matches when the context-clicked node doesn't have any of
 // NON_PAGE_CONTEXT_ELTS in its ancestors
 let PageContext = Class({
   extends: Context,
 
-  isCurrent: function isCurrent(popupNode) {
-    // If there is a selection in the window then this context does not match
-    if (!popupNode.ownerDocument.defaultView.getSelection().isCollapsed)
-      return false;
-
-    // If the clicked node or any of its ancestors is one of the blacklisted
-    // NON_PAGE_CONTEXT_ELTS then this context does not match
-    while (!(popupNode instanceof Ci.nsIDOMDocument)) {
-      if (NON_PAGE_CONTEXT_ELTS.some(function(type) popupNode instanceof type))
-        return false;
-
-      popupNode = popupNode.parentNode;
+  serialize: function() {
+    return {
+      id: internal(this).id,
+      type: "PageContext",
+      args: []
     }
-
-    return true;
   }
 });
 exports.PageContext = PageContext;
 
 // Matches when there is an active selection in the window
 let SelectionContext = Class({
   extends: Context,
 
-  isCurrent: function isCurrent(popupNode) {
-    if (!popupNode.ownerDocument.defaultView.getSelection().isCollapsed)
-      return true;
-
-    try {
-      // The node may be a text box which has selectionStart and selectionEnd
-      // properties. If not this will throw.
-      let { selectionStart, selectionEnd } = popupNode;
-      return !isNaN(selectionStart) && !isNaN(selectionEnd) &&
-             selectionStart !== selectionEnd;
-    }
-    catch (e) {
-      return false;
+  serialize: function() {
+    return {
+      id: internal(this).id,
+      type: "SelectionContext",
+      args: []
     }
   }
 });
 exports.SelectionContext = SelectionContext;
 
 // Matches when the context-clicked node or any of its ancestors matches the
 // selector given
 let SelectorContext = Class({
   extends: Context,
 
   initialize: function initialize(selector) {
+    Context.prototype.initialize.call(this);
     let options = validateOptions({ selector: selector }, {
       selector: {
         is: ["string"],
         msg: "selector must be a string."
       }
     });
     internal(this).selector = options.selector;
   },
 
-  adjustPopupNode: function adjustPopupNode(popupNode) {
-    let selector = internal(this).selector;
-
-    while (!(popupNode instanceof Ci.nsIDOMDocument)) {
-      if (popupNode.mozMatchesSelector(selector))
-        return popupNode;
-
-      popupNode = popupNode.parentNode;
+  serialize: function() {
+    return {
+      id: internal(this).id,
+      type: "SelectorContext",
+      args: [internal(this).selector]
     }
-
-    return null;
-  },
-
-  isCurrent: function isCurrent(popupNode) {
-    return !!this.adjustPopupNode(popupNode);
   }
 });
 exports.SelectorContext = SelectorContext;
 
 // Matches when the page url matches any of the patterns given
 let URLContext = Class({
   extends: Context,
 
   initialize: function initialize(patterns) {
+    Context.prototype.initialize.call(this);
     patterns = Array.isArray(patterns) ? patterns : [patterns];
 
     try {
       internal(this).patterns = patterns.map(function (p) new MatchPattern(p));
     }
     catch (err) {
       throw new Error("Patterns must be a string, regexp or an array of " +
                       "strings or regexps: " + err);
     }
+  },
 
+  isCurrent: function isCurrent(url) {
+    return internal(this).patterns.some(function (p) p.test(url));
   },
 
-  isCurrent: function isCurrent(popupNode) {
-    let url = popupNode.ownerDocument.URL;
-    return internal(this).patterns.some(function (p) p.test(url));
+  serialize: function() {
+    return {
+      id: internal(this).id,
+      type: "URLContext",
+      args: []
+    }
   }
 });
 exports.URLContext = URLContext;
 
 // Matches when the user-supplied predicate returns true
 let PredicateContext = Class({
   extends: Context,
 
   initialize: function initialize(predicate) {
+    Context.prototype.initialize.call(this);
     let options = validateOptions({ predicate: predicate }, {
       predicate: {
         is: ["function"],
         msg: "predicate must be a function."
       }
     });
     internal(this).predicate = options.predicate;
   },
 
-  isCurrent: function isCurrent(popupNode) {
-    return internal(this).predicate(populateCallbackNodeData(popupNode));
+  isCurrent: function isCurrent(state) {
+    return internal(this).predicate(state);
+  },
+
+  serialize: function() {
+    return {
+      id: internal(this).id,
+      type: "PredicateContext",
+      args: []
+    }
   }
 });
 exports.PredicateContext = PredicateContext;
 
-// List all editable types of inputs.  Or is it better to have a list
-// of non-editable inputs?
-let editableInputs = {
-  email: true,
-  number: true,
-  password: true,
-  search: true,
-  tel: true,
-  text: true,
-  textarea: true,
-  url: true
-};
-
-function populateCallbackNodeData(node) {
-  let window = node.ownerDocument.defaultView;
-  let data = {};
-
-  data.documentType = node.ownerDocument.contentType;
-
-  data.documentURL = node.ownerDocument.location.href;
-  data.targetName = node.nodeName.toLowerCase();
-  data.targetID = node.id || null ;
-
-  if ((data.targetName === 'input' && editableInputs[node.type]) ||
-      data.targetName === 'textarea') {
-    data.isEditable = !node.readOnly && !node.disabled;
-  }
-  else {
-    data.isEditable = node.isContentEditable;
-  }
-
-  data.selectionText = selection.text;
-
-  data.srcURL = node.src || null;
-  data.value = node.value || null;
-
-  while (!data.linkURL && node) {
-    data.linkURL = node.href || null;
-    node = node.parentNode;
-  }
-
-  return data;
-}
-
 function removeItemFromArray(array, item) {
   return array.filter(function(i) i !== item);
 }
 
 // Converts anything that isn't false, null or undefined into a string
 function stringOrNull(val) val ? String(val) : val;
 
 // Shared option validation rules for Item, Menu, and Separator
@@ -357,183 +300,191 @@ let menuRules = mix(labelledItemRules, {
         return item instanceof BaseItem;
       });
     },
     msg: "items must be an array, and each element in the array must be an " +
          "Item, Menu, or Separator."
   }
 });
 
-let ContextWorker = Class({
-  implements: [ Worker ],
-
-  // Calls the context workers context listeners and returns the first result
-  // that is either a string or a value that evaluates to true. If all of the
-  // listeners returned false then returns false. If there are no listeners,
-  // returns true (show the menu item by default).
-  getMatchedContext: function getCurrentContexts(popupNode) {
-    let results = this.getSandbox().emitSync("context", popupNode);
-    if (!results.length)
-      return true;
-    return results.reduce((val, result) => val || result);
-  },
-
-  // Emits a click event in the worker's port. popupNode is the node that was
-  // context-clicked, and clickedItemData is the data of the item that was
-  // clicked.
-  fireClick: function fireClick(popupNode, clickedItemData) {
-    this.getSandbox().emitSync("click", popupNode, clickedItemData);
-  }
-});
-
 // Returns true if any contexts match. If there are no contexts then a
 // PageContext is tested instead
-function hasMatchingContext(contexts, popupNode) {
-  for (let context in contexts) {
-    if (!context.isCurrent(popupNode))
+function hasMatchingContext(contexts, addonInfo) {
+  for (let context of contexts) {
+    if (!(internal(context).id in addonInfo.contextStates)) {
+      console.error("Missing state for context " + internal(context).id + " this is an error in the SDK modules.");
+      return false;
+    }
+    if (!context.isCurrent(addonInfo.contextStates[internal(context).id]))
       return false;
   }
 
   return true;
 }
 
-// Gets the matched context from any worker for this item. If there is no worker
-// or no matched context then returns false.
-function getCurrentWorkerContext(item, popupNode) {
-  let worker = getItemWorkerForWindow(item, popupNode.ownerDocument.defaultView);
-  if (!worker)
-    return true;
-  return worker.getMatchedContext(popupNode);
-}
-
 // Tests whether an item should be visible or not based on its contexts and
 // content scripts
-function isItemVisible(item, popupNode, defaultVisibility) {
+function isItemVisible(item, addonInfo, usePageWorker) {
   if (!item.context.length) {
-    let worker = getItemWorkerForWindow(item, popupNode.ownerDocument.defaultView);
-    if (!worker)
-      return defaultVisibility;
+    if (!addonInfo.hasWorker)
+      return usePageWorker ? addonInfo.pageContext : true;
   }
 
-  if (!hasMatchingContext(item.context, popupNode))
+  if (!hasMatchingContext(item.context, addonInfo))
     return false;
 
-  let context = getCurrentWorkerContext(item, popupNode);
+  let context = addonInfo.workerContext;
   if (typeof(context) === "string" && context != "")
     item.label = context;
 
   return !!context;
 }
 
-// Gets the item's content script worker for a window, creating one if necessary
-// Once created it will be automatically destroyed when the window unloads.
-// If there is not content scripts for the item then null will be returned.
-function getItemWorkerForWindow(item, window) {
-  if (!item.contentScript && !item.contentScriptFile)
-    return null;
+// Called when an item is clicked to send out click events to the content
+// scripts
+function itemActivated(item, clickedNode) {
+  let data = {
+    items: [internal(item).id],
+    data: item.data,
+  }
 
-  let id = getInnerId(window);
-  let worker = internal(item).workerMap.get(id);
+  while (item.parentMenu) {
+    item = item.parentMenu;
+    data.items.push(internal(item).id);
+  }
 
-  if (worker)
-    return worker;
+  let menuData = clickedNode.ownerDocument.defaultView.gContextMenuContentData;
+  let messageManager = menuData.browser.messageManager;
+  messageManager.sendAsyncMessage('sdk/contextmenu/activateitems', data, {
+    popupNode: menuData.popupNode
+  });
+}
 
-  worker = ContextWorker({
-    window: window,
+function serializeItem(item) {
+  return {
+    id: internal(item).id,
+    contexts: [c.serialize() for (c of item.context)],
     contentScript: item.contentScript,
     contentScriptFile: item.contentScriptFile,
-    onMessage: function(msg) {
-      emit(item, "message", msg);
-    },
-    onDetach: function() {
-      internal(item).workerMap.delete(id);
-    }
-  });
-
-  internal(item).workerMap.set(id, worker);
-
-  return worker;
-}
-
-// Called when an item is clicked to send out click events to the content
-// scripts
-function itemActivated(item, clickedItem, popupNode) {
-  let worker = getItemWorkerForWindow(item, popupNode.ownerDocument.defaultView);
-
-  if (worker) {
-    let adjustedNode = popupNode;
-    for (let context in item.context)
-        adjustedNode = context.adjustPopupNode(adjustedNode);
-    worker.fireClick(adjustedNode, clickedItem.data);
-  }
-
-  if (item.parentMenu)
-    itemActivated(item.parentMenu, clickedItem, popupNode);
+  };
 }
 
 // All things that appear in the context menu extend this
 let BaseItem = Class({
   initialize: function initialize() {
-    addCollectionProperty(this, "context");
+    internal(this).id = uuid();
 
-    // Used to cache content script workers and the windows they have been
-    // created for
-    internal(this).workerMap = new Map();
-
+    internal(this).contexts = [];
     if ("context" in internal(this).options && internal(this).options.context) {
       let contexts = internal(this).options.context;
       if (Array.isArray(contexts)) {
         for (let context of contexts)
-          this.context.add(context);
+          internal(this).contexts.push(context);
       }
       else {
-        this.context.add(contexts);
+        internal(this).contexts.push(contexts);
       }
     }
 
     let parentMenu = internal(this).options.parentMenu;
     if (!parentMenu)
       parentMenu = contentContextMenu;
 
     parentMenu.addItem(this);
 
     Object.defineProperty(this, "contentScript", {
       enumerable: true,
       value: internal(this).options.contentScript
     });
 
+    // Resolve URIs here as tests may have overriden self
+    let files = internal(this).options.contentScriptFile;
+    if (files) {
+      if (!Array.isArray(files))
+        files = [files];
+      files = files.map(self.data.url);
+    }
+    internal(this).options.contentScriptFile = files;
     Object.defineProperty(this, "contentScriptFile", {
       enumerable: true,
       value: internal(this).options.contentScriptFile
     });
+
+    // Notify all frames of this new item
+    sendItems([serializeItem(this)]);
   },
 
   destroy: function destroy() {
+    if (internal(this).destroyed)
+      return;
+
+    // Tell all existing frames that this item has been destroyed
+    MessageManager.broadcastAsyncMessage("sdk/contextmenu/destroyitems", {
+      items: [internal(this).id]
+    });
+
     if (this.parentMenu)
       this.parentMenu.removeItem(this);
+
+    internal(this).destroyed = true;
+  },
+
+  get context() {
+    let contexts = internal(this).contexts.slice(0);
+    contexts.add = (context) => {
+      internal(this).contexts.push(context);
+      // Notify all frames that this item has changed
+      sendItems([serializeItem(this)]);
+    };
+    contexts.remove = (context) => {
+      internal(this).contexts = internal(this).contexts.filter(c => {
+        return c != context;
+      });
+      // Notify all frames that this item has changed
+      sendItems([serializeItem(this)]);
+    };
+    return contexts;
+  },
+
+  set context(val) {
+    internal(this).contexts = val.slice(0);
+    // Notify all frames that this item has changed
+    sendItems([serializeItem(this)]);
   },
 
   get parentMenu() {
     return internal(this).parentMenu;
   },
 });
 
+function workerMessageReceived({ data: { id, args } }) {
+  if (internal(this).id != id)
+    return;
+
+  emit(this, ...args);
+}
+
 // All things that have a label on the context menu extend this
 let LabelledItem = Class({
   extends: BaseItem,
   implements: [ EventTarget ],
 
   initialize: function initialize(options) {
     BaseItem.prototype.initialize.call(this);
     EventTarget.prototype.initialize.call(this, options);
+
+    internal(this).messageListener = workerMessageReceived.bind(this);
+    MessageManager.addMessageListener('sdk/worker/event', internal(this).messageListener);
   },
 
   destroy: function destroy() {
-    for (let [,worker] of internal(this).workerMap)
-      worker.destroy();
+    if (internal(this).destroyed)
+      return;
+
+    MessageManager.removeMessageListener('sdk/worker/event', internal(this).messageListener);
 
     BaseItem.prototype.destroy.call(this);
   },
 
   get label() {
     return internal(this).options.label;
   },
 
@@ -707,17 +658,49 @@ let Separator = Class({
   }
 });
 exports.Separator = Separator;
 
 // Holds items for the content area context menu
 let contentContextMenu = ItemContainer();
 exports.contentContextMenu = contentContextMenu;
 
+function getContainerItems(container) {
+  let items = [];
+  for (let item of internal(container).children) {
+    items.push(serializeItem(item));
+    if (item instanceof Menu)
+      items = items.concat(getContainerItems(item));
+  }
+  return items;
+}
+
+// Notify all frames of these new or changed items
+function sendItems(items) {
+  MessageManager.broadcastAsyncMessage("sdk/contextmenu/createitems", {
+    items,
+    addon: ADDON,
+  });
+}
+
+// Called when a new frame is created and wants to get the current list of items
+function remoteItemRequest({ target: { messageManager } }) {
+  let items = getContainerItems(contentContextMenu);
+  if (items.length == 0)
+    return;
+
+  messageManager.sendAsyncMessage("sdk/contextmenu/createitems", {
+    items,
+    addon: ADDON,
+  });
+}
+MessageManager.addMessageListener('sdk/contextmenu/requestitems', remoteItemRequest);
+
 when(function() {
+  MessageManager.removeMessageListener('sdk/contextmenu/requestitems', remoteItemRequest);
   contentContextMenu.destroy();
 });
 
 // App specific UI code lives here, it should handle populating the context
 // menu and passing clicks etc. through to the items.
 
 function countVisibleItems(nodes) {
   return Array.reduce(nodes, function(sum, node) {
@@ -795,26 +778,26 @@ let MenuWrapper = Class({
 
       if (item instanceof Menu)
         this.populate(item);
     }
   },
 
   // Recurses through the menu setting the visibility of items. Returns true
   // if any of the items in this menu were visible
-  setVisibility: function setVisibility(menu, popupNode, defaultVisibility) {
+  setVisibility: function setVisibility(menu, addonInfo, usePageWorker) {
     let anyVisible = false;
 
     for (let item of internal(menu).children) {
-      let visible = isItemVisible(item, popupNode, defaultVisibility);
+      let visible = isItemVisible(item, addonInfo[internal(item).id], usePageWorker);
 
       // Recurse through Menus, if none of the sub-items were visible then the
       // menu is hidden too.
       if (visible && (item instanceof Menu))
-        visible = this.setVisibility(item, popupNode, true);
+        visible = this.setVisibility(item, addonInfo, false);
 
       let xulNode = this.getXULNodeForItem(item);
       xulNode.hidden = !visible;
 
       anyVisible = anyVisible || visible;
     }
 
     return anyVisible;
@@ -907,17 +890,17 @@ let MenuWrapper = Class({
         xulNode.setAttribute("value", item.data);
 
       let self = this;
       xulNode.addEventListener("command", function(event) {
         // Only care about clicks directly on this item
         if (event.target !== xulNode)
           return;
 
-        itemActivated(item, item, self.contextMenu.triggerNode);
+        itemActivated(item, xulNode);
       }, false);
     }
 
     this.insertIntoXUL(item, xulNode, after);
     this.updateXULClass(xulNode);
     xulNode.data = item.data;
 
     if (item instanceof Menu) {
@@ -1022,18 +1005,24 @@ let MenuWrapper = Class({
       if (internal(this.items).children.length == 0)
         return;
 
       if (!this.populated) {
         this.populated = true;
         this.populate(this.items);
       }
 
-      let popupNode = event.target.triggerNode;
-      this.setVisibility(this.items, popupNode, PageContext().isCurrent(popupNode));
+      let mainWindow = event.target.ownerDocument.defaultView;
+      this.contextMenuContentData = mainWindow.gContextMenuContentData
+      let addonInfo = this.contextMenuContentData.addonInfo[self.id];
+      if (!addonInfo) {
+        console.warn("No context menu state data was provided.");
+        return;
+      }
+      this.setVisibility(this.items, addonInfo, true);
     }
     catch (e) {
       console.exception(e);
     }
   },
 
   // Counts the number of visible items across all modules and makes sure they
   // are in the right place between the top level context menu and the overflow
--- a/addon-sdk/source/test/test-context-menu.js
+++ b/addon-sdk/source/test/test-context-menu.js
@@ -6,16 +6,17 @@
 let { Cc, Ci } = require("chrome");
 
 require("sdk/context-menu");
 
 const { Loader } = require('sdk/test/loader');
 const timer = require("sdk/timers");
 const { merge } = require("sdk/util/object");
 const { defer } = require("sdk/core/promise");
+const observers = require("sdk/system/events");
 
 // These should match the same constants in the module.
 const ITEM_CLASS = "addon-context-menu-item";
 const SEPARATOR_CLASS = "addon-context-menu-separator";
 const OVERFLOW_THRESH_DEFAULT = 10;
 const OVERFLOW_THRESH_PREF =
   "extensions.addon-sdk.context-menu.overflowThreshold";
 const OVERFLOW_MENU_CLASS = "addon-content-menu-overflow-menu";
@@ -98,17 +99,17 @@ exports.testSelectorContextMatch = funct
 
   let item = new loader.cm.Item({
     label: "item",
     data: "item",
     context: loader.cm.SelectorContext("img")
   });
 
   test.withTestDoc(function (window, doc) {
-    test.showMenu(doc.getElementById("image"), function (popup) {
+    test.showMenu("#image", function (popup) {
       test.checkMenu([item], [], []);
       test.done();
     });
   });
 };
 
 
 // CSS selector contexts should cause their items to be present in the menu
@@ -120,17 +121,17 @@ exports.testSelectorAncestorContextMatch
 
   let item = new loader.cm.Item({
     label: "item",
     data: "item",
     context: loader.cm.SelectorContext("a[href]")
   });
 
   test.withTestDoc(function (window, doc) {
-    test.showMenu(doc.getElementById("span-link"), function (popup) {
+    test.showMenu("#span-link", function (popup) {
       test.checkMenu([item], [], []);
       test.done();
     });
   });
 };
 
 
 // CSS selector contexts should cause their items to be absent from the menu
@@ -204,17 +205,17 @@ exports.testPageContextNoMatch = functio
     }),
     new loader.cm.Item({
       label: "item 3",
       context: [loader.cm.PageContext()]
     })
   ];
 
   test.withTestDoc(function (window, doc) {
-    test.showMenu(doc.getElementById("image"), function (popup) {
+    test.showMenu("#image", function (popup) {
       test.checkMenu(items, items, []);
       test.done();
     });
   });
 };
 
 
 // Selection contexts should cause items to appear when a selection exists.
@@ -244,19 +245,18 @@ exports.testSelectionContextMatchInTextF
   let loader = test.newLoader();
 
   let item = loader.cm.Item({
     label: "item",
     context: loader.cm.SelectionContext()
   });
 
   test.withTestDoc(function (window, doc) {
-    let textfield = doc.getElementById("textfield");
-    textfield.setSelectionRange(0, textfield.value.length);
-    test.showMenu(textfield, function (popup) {
+    test.selectRange("#textfield", 0, null);
+    test.showMenu("#textfield", function (popup) {
       test.checkMenu([item], [], []);
       test.done();
     });
   });
 };
 
 
 // Selection contexts should not cause items to appear when a selection does
@@ -266,19 +266,18 @@ exports.testSelectionContextNoMatchInTex
   let loader = test.newLoader();
 
   let item = loader.cm.Item({
     label: "item",
     context: loader.cm.SelectionContext()
   });
 
   test.withTestDoc(function (window, doc) {
-    let textfield = doc.getElementById("textfield");
-    textfield.setSelectionRange(0, 0);
-    test.showMenu(textfield, function (popup) {
+    test.selectRange("#textfield", 0, 0);
+    test.showMenu("#textfield", function (popup) {
       test.checkMenu([item], [item], []);
       test.done();
     });
   });
 };
 
 
 // Selection contexts should not cause items to appear when a selection does
@@ -309,53 +308,58 @@ exports.testSelectionContextInNewTab = f
     label: "item",
     context: loader.cm.SelectionContext()
   });
 
   test.withTestDoc(function (window, doc) {
     let link = doc.getElementById("targetlink");
     link.click();
 
-    test.delayedEventListener(this.tabBrowser, "load", function () {
-      let browser = test.tabBrowser.selectedBrowser;
-      let window = browser.contentWindow;
-      let doc = browser.contentDocument;
-      window.getSelection().selectAllChildren(doc.body);
-
-      test.showMenu(null, function (popup) {
-        test.checkMenu([item], [], []);
-        popup.hidePopup();
-
-        test.tabBrowser.removeTab(test.tabBrowser.selectedTab);
-        test.tabBrowser.selectedTab = test.tab;
+    let tablistener = event => {
+      this.tabBrowser.tabContainer.removeEventListener("TabOpen", tablistener, false);
+      let tab = event.target;
+      let browser = tab.linkedBrowser;
+      this.loadFrameScript(browser);
+      this.delayedEventListener(browser, "load", () => {
+        let window = browser.contentWindow;
+        let doc = browser.contentDocument;
+        window.getSelection().selectAllChildren(doc.body);
 
         test.showMenu(null, function (popup) {
-          test.checkMenu([item], [item], []);
-          test.done();
+          test.checkMenu([item], [], []);
+          popup.hidePopup();
+
+          test.tabBrowser.removeTab(test.tabBrowser.selectedTab);
+          test.tabBrowser.selectedTab = test.tab;
+
+          test.showMenu(null, function (popup) {
+            test.checkMenu([item], [item], []);
+            test.done();
+          });
         });
-      });
-    }, true);
+      }, true);
+    };
+    this.tabBrowser.tabContainer.addEventListener("TabOpen", tablistener, false);
   });
 };
 
 
 // Selection contexts should work when right clicking a form button
 exports.testSelectionContextButtonMatch = function (assert, done) {
   let test = new TestHelper(assert, done);
   let loader = test.newLoader();
 
   let item = loader.cm.Item({
     label: "item",
     context: loader.cm.SelectionContext()
   });
 
   test.withTestDoc(function (window, doc) {
     window.getSelection().selectAllChildren(doc.body);
-    let button = doc.getElementById("button");
-    test.showMenu(button, function (popup) {
+    test.showMenu("#button", function (popup) {
       test.checkMenu([item], [], []);
       test.done();
     });
   });
 };
 
 
 //Selection contexts should work when right clicking a form button
@@ -364,18 +368,17 @@ exports.testSelectionContextButtonNoMatc
   let loader = test.newLoader();
 
   let item = loader.cm.Item({
     label: "item",
     context: loader.cm.SelectionContext()
   });
 
   test.withTestDoc(function (window, doc) {
-    let button = doc.getElementById("button");
-    test.showMenu(button, function (popup) {
+    test.showMenu("#button", function (popup) {
       test.checkMenu([item], [item], []);
       test.done();
     });
   });
 };
 
 
 // URL contexts should cause items to appear on pages that match.
@@ -431,65 +434,16 @@ exports.testURLContextNoMatch = function
     test.showMenu(null, function (popup) {
       test.checkMenu(items, items, []);
       test.done();
     });
   });
 };
 
 
-// Removing a non-matching URL context after its item is created and the page is
-// loaded should cause the item's content script to be evaluated when the
-// context menu is next opened.
-exports.testURLContextRemove = function (assert, done) {
-  let test = new TestHelper(assert, done);
-  let loader = test.newLoader();
-
-  let shouldBeEvaled = false;
-  let context = loader.cm.URLContext("*.bogus.com");
-  let item = loader.cm.Item({
-    label: "item",
-    context: context,
-    contentScript: 'self.postMessage("ok"); self.on("context", function () true);',
-    onMessage: function (msg) {
-      assert.ok(shouldBeEvaled,
-                  "content script should be evaluated when expected");
-      assert.equal(msg, "ok", "Should have received the right message");
-      shouldBeEvaled = false;
-    }
-  });
-
-  test.withTestDoc(function (window, doc) {
-    test.showMenu(null, function (popup) {
-      test.checkMenu([item], [item], []);
-
-      item.context.remove(context);
-
-      shouldBeEvaled = true;
-
-      test.hideMenu(function () {
-        test.showMenu(null, function (popup) {
-          test.checkMenu([item], [], []);
-
-          assert.ok(!shouldBeEvaled,
-                      "content script should have been evaluated");
-
-          test.hideMenu(function () {
-            // Shouldn't get evaluated again
-            test.showMenu(null, function (popup) {
-              test.checkMenu([item], [], []);
-              test.done();
-            });
-          });
-        });
-      });
-    });
-  });
-};
-
 // Loading a new page in the same tab should correctly start a new worker for
 // any content scripts
 exports.testPageReload = function (assert, done) {
   let test = new TestHelper(assert, done);
   let loader = test.newLoader();
 
   let item = loader.cm.Item({
     label: "Item",
@@ -767,17 +721,17 @@ exports.testContentContextMatchActiveEle
     new loader.cm.Item({
       label: "item 4",
       context: [loader.cm.PageContext()],
       contentScript: 'self.on("context", function () true);'
     })
   ];
 
   test.withTestDoc(function (window, doc) {
-    test.showMenu(doc.getElementById("image"), function (popup) {
+    test.showMenu("#image", function (popup) {
       test.checkMenu(items, [items[2], items[3]], []);
       test.done();
     });
   });
 };
 
 
 // Content contexts that return false should cause their items to be absent
@@ -805,17 +759,17 @@ exports.testContentContextNoMatchActiveE
     new loader.cm.Item({
       label: "item 4",
       context: [loader.cm.PageContext()],
       contentScript: 'self.on("context", function () false);'
     })
   ];
 
   test.withTestDoc(function (window, doc) {
-    test.showMenu(doc.getElementById("image"), function (popup) {
+    test.showMenu("#image", function (popup) {
       test.checkMenu(items, items, []);
       test.done();
     });
   });
 };
 
 
 // Content contexts that return undefined should cause their items to be absent
@@ -843,17 +797,17 @@ exports.testContentContextNoMatchActiveE
     new loader.cm.Item({
       label: "item 4",
       context: [loader.cm.PageContext()],
       contentScript: 'self.on("context", function () {});'
     })
   ];
 
   test.withTestDoc(function (window, doc) {
-    test.showMenu(doc.getElementById("image"), function (popup) {
+    test.showMenu("#image", function (popup) {
       test.checkMenu(items, items, []);
       test.done();
     });
   });
 };
 
 
 // Content contexts that return a string should cause their items to be present
@@ -910,17 +864,16 @@ exports.testContentScriptFile = function
     label: "item2",
     contentScriptFile: "./test-contentScriptFile.js",
     onMessage: (message) => {
       assert.equal(message, "msg from contentScriptFile",
         "contentScriptFile loaded with relative url");
       itemScript[1].resolve();
     }
   });
-  console.log(item.contentScriptFile, item2.contentScriptFile);
 
   test.showMenu(null, function (popup) {
     test.checkMenu([item, item2], [], []);
     menuShown.resolve();
   });
 
   all(menuPromises).then(() => test.done());
 };
@@ -944,33 +897,28 @@ exports.testContentContextArgs = functio
     }
   });
 
   test.showMenu(null, function () {
     if (++callbacks == 2) test.done();
   });
 };
 
-// Multiple contexts imply intersection, not union, and content context
-// listeners should not be called if all declarative contexts are not current.
+// Multiple contexts imply intersection, not union.
 exports.testMultipleContexts = function (assert, done) {
   let test = new TestHelper(assert, done);
   let loader = test.newLoader();
 
   let item = new loader.cm.Item({
     label: "item",
     context: [loader.cm.SelectorContext("a[href]"), loader.cm.PageContext()],
-    contentScript: 'self.on("context", function () self.postMessage());',
-    onMessage: function () {
-      test.fail("Context listener should not be called");
-    }
   });
 
   test.withTestDoc(function (window, doc) {
-    test.showMenu(doc.getElementById("span-link"), function (popup) {
+    test.showMenu("#span-link", function (popup) {
       test.checkMenu([item], [item], []);
       test.done();
     });
   });
 };
 
 // Once a context is removed, it should no longer cause its item to appear.
 exports.testRemoveContext = function (assert, done) {
@@ -979,32 +927,113 @@ exports.testRemoveContext = function (as
 
   let ctxt = loader.cm.SelectorContext("img");
   let item = new loader.cm.Item({
     label: "item",
     context: ctxt
   });
 
   test.withTestDoc(function (window, doc) {
-    test.showMenu(doc.getElementById("image"), function (popup) {
+    test.showMenu("#image", function (popup) {
 
       // The item should be present at first.
       test.checkMenu([item], [], []);
       popup.hidePopup();
 
       // Remove the img context and check again.
       item.context.remove(ctxt);
-      test.showMenu(doc.getElementById("image"), function (popup) {
+      test.showMenu("#image", function (popup) {
+        test.checkMenu([item], [item], []);
+        test.done();
+      });
+    });
+  });
+};
+
+// Once a context is removed, it should no longer cause its item to appear.
+exports.testSetContextRemove = function (assert, done) {
+  let test = new TestHelper(assert, done);
+  let loader = test.newLoader();
+
+  let ctxt = loader.cm.SelectorContext("img");
+  let item = new loader.cm.Item({
+    label: "item",
+    context: ctxt
+  });
+
+  test.withTestDoc(function (window, doc) {
+    test.showMenu("#image", function (popup) {
+
+      // The item should be present at first.
+      test.checkMenu([item], [], []);
+      popup.hidePopup();
+
+      // Remove the img context and check again.
+      item.context = [];
+      test.showMenu("#image", function (popup) {
         test.checkMenu([item], [item], []);
         test.done();
       });
     });
   });
 };
 
+// Once a context is added, it should affect whether the item appears.
+exports.testAddContext = function (assert, done) {
+  let test = new TestHelper(assert, done);
+  let loader = test.newLoader();
+
+  let ctxt = loader.cm.SelectorContext("img");
+  let item = new loader.cm.Item({
+    label: "item"
+  });
+
+  test.withTestDoc(function (window, doc) {
+    test.showMenu("#image", function (popup) {
+
+      // The item should not be present at first.
+      test.checkMenu([item], [item], []);
+      popup.hidePopup();
+
+      // Add the img context and check again.
+      item.context.add(ctxt);
+      test.showMenu("#image", function (popup) {
+        test.checkMenu([item], [], []);
+        test.done();
+      });
+    });
+  });
+};
+
+// Once a context is added, it should affect whether the item appears.
+exports.testSetContextAdd = function (assert, done) {
+  let test = new TestHelper(assert, done);
+  let loader = test.newLoader();
+
+  let ctxt = loader.cm.SelectorContext("img");
+  let item = new loader.cm.Item({
+    label: "item"
+  });
+
+  test.withTestDoc(function (window, doc) {
+    test.showMenu("#image", function (popup) {
+
+      // The item should not be present at first.
+      test.checkMenu([item], [item], []);
+      popup.hidePopup();
+
+      // Add the img context and check again.
+      item.context = [ctxt];
+      test.showMenu("#image", function (popup) {
+        test.checkMenu([item], [], []);
+        test.done();
+      });
+    });
+  });
+};
 
 // Lots of items should overflow into the overflow submenu.
 exports.testOverflow = function (assert, done) {
   let test = new TestHelper(assert, done);
   let loader = test.newLoader();
 
   let items = [];
   for (let i = 0; i < OVERFLOW_THRESH_DEFAULT + 1; i++) {
@@ -1631,47 +1660,47 @@ exports.testOverflowTransition = functio
       label: "item 3",
       context: loader.cm.SelectorContext("a")
     })
   ];
 
   let allItems = pItems.concat(aItems);
 
   test.withTestDoc(function (window, doc) {
-    test.showMenu(doc.getElementById("link"), function (popup) {
+    test.showMenu("#link", function (popup) {
       // The menu should contain all items and will overflow
       test.checkMenu(allItems, [], []);
       popup.hidePopup();
 
-      test.showMenu(doc.getElementById("text"), function (popup) {
+      test.showMenu("#text", function (popup) {
         // Only contains hald the items and will not overflow
         test.checkMenu(allItems, aItems, []);
         popup.hidePopup();
 
         test.showMenu(null, function (popup) {
           // None of the items will be visible
           test.checkMenu(allItems, allItems, []);
           popup.hidePopup();
 
-          test.showMenu(doc.getElementById("text"), function (popup) {
+          test.showMenu("#text", function (popup) {
             // Only contains hald the items and will not overflow
             test.checkMenu(allItems, aItems, []);
             popup.hidePopup();
 
-            test.showMenu(doc.getElementById("link"), function (popup) {
+            test.showMenu("#link", function (popup) {
               // The menu should contain all items and will overflow
               test.checkMenu(allItems, [], []);
               popup.hidePopup();
 
               test.showMenu(null, function (popup) {
                 // None of the items will be visible
                 test.checkMenu(allItems, allItems, []);
                 popup.hidePopup();
 
-                test.showMenu(doc.getElementById("link"), function (popup) {
+                test.showMenu("#link", function (popup) {
                   // The menu should contain all items and will overflow
                   test.checkMenu(allItems, [], []);
                   test.done();
                 });
               });
             });
           });
         });
@@ -1753,17 +1782,17 @@ exports.testMenuCommand = function (asse
                        "Clicked item data should be correct");
       test.done();
     },
     items: [submenu],
     context: loader.cm.SelectorContext("a")
   });
 
   test.withTestDoc(function (window, doc) {
-    test.showMenu(doc.getElementById("span-link"), function (popup) {
+    test.showMenu("#span-link", function (popup) {
       test.checkMenu([topMenu], [], []);
       let topMenuElt = test.getItemElt(popup, topMenu);
       let topMenuPopup = topMenuElt.firstChild;
       let submenuElt = test.getItemElt(topMenuPopup, submenu);
       let submenuPopup = submenuElt.firstChild;
       let itemElt = test.getItemElt(submenuPopup, item);
 
       // create a command event
@@ -1879,17 +1908,17 @@ exports.testMenuClick = function (assert
                        "Clicked item data should be correct");
       test.done();
     },
     items: [submenu],
     context: loader.cm.SelectorContext("a")
   });
 
   test.withTestDoc(function (window, doc) {
-    test.showMenu(doc.getElementById("span-link"), function (popup) {
+    test.showMenu("#span-link", function (popup) {
       test.checkMenu([topMenu], [], []);
       let topMenuElt = test.getItemElt(popup, topMenu);
       let topMenuPopup = topMenuElt.firstChild;
       let submenuElt = test.getItemElt(topMenuPopup, submenu);
       let submenuPopup = submenuElt.firstChild;
       let itemElt = test.getItemElt(submenuPopup, item);
       itemElt.click();
     });
@@ -2219,17 +2248,17 @@ exports.testDrawImageOnClickNode = funct
           self.postMessage("done");
         });
       },
       onMessage: function (msg) {
         if (msg === "done")
           test.done();
       }
     });
-    test.showMenu(doc.getElementById("image"), function (popup) {
+    test.showMenu("#image", function (popup) {
       test.checkMenu([item], [], []);
       test.getItemElt(popup, item).click();
     });
   });
 };
 
 
 // Setting an item's label before the menu is ever shown should correctly change
@@ -2555,17 +2584,17 @@ exports.testItemDataSetter = function (a
 // clicking the iframe.
 exports.testAlreadyOpenIframe = function (assert, done) {
   let test = new TestHelper(assert, done);
   test.withTestDoc(function (window, doc) {
     let loader = test.newLoader();
     let item = new loader.cm.Item({
       label: "item"
     });
-    test.showMenu(doc.getElementById("iframe"), function (popup) {
+    test.showMenu("#iframe", function (popup) {
       test.checkMenu([item], [], []);
       test.done();
     });
   });
 };
 
 
 // Tests that a missing label throws an exception
@@ -2999,17 +3028,17 @@ exports.testSubItemDefaultVisible = func
       ]
     })
   ];
 
   // subitem 3 will be hidden
   let hiddenItems = [items[0].items[2]];
 
   test.withTestDoc(function (window, doc) {
-    test.showMenu(doc.getElementById("image"), function (popup) {
+    test.showMenu("#image", function (popup) {
       test.checkMenu(items, hiddenItems, []);
       test.done();
     });
   });
 };
 
 // Tests that the click event on sub menuitem
 // tiggers the click event for the sub menuitem and the parent menu
@@ -3170,17 +3199,17 @@ exports.testSelectionInInnerFrameMatch =
       context: loader.cm.SelectionContext()
     })
   ];
 
   test.withTestDoc(function (window, doc) {
     let frame = doc.getElementById("iframe");
     frame.contentWindow.getSelection().selectAllChildren(frame.contentDocument.body);
 
-    test.showMenu(frame.contentDocument.getElementById("text"), function (popup) {
+    test.showMenu(["#iframe", "#text"], function (popup) {
       test.checkMenu(items, [], []);
       test.done();
     });
   });
 };
 
 // Tests that opening a context menu for an inner frame when the outer frame
 // has a selection doesn't activate the SelectionContext
@@ -3196,17 +3225,17 @@ exports.testSelectionInOuterFrameNoMatch
       context: loader.cm.SelectionContext()
     })
   ];
 
   test.withTestDoc(function (window, doc) {
     let frame = doc.getElementById("iframe");
     window.getSelection().selectAllChildren(doc.body);
 
-    test.showMenu(frame.contentDocument.getElementById("text"), function (popup) {
+    test.showMenu(["#iframe", "#text"], function (popup) {
       test.checkMenu(items, items, []);
       test.done();
     });
   });
 };
 
 
 // Test that the return value of the predicate function determines if
@@ -3283,17 +3312,17 @@ exports.testPredicateContextTargetName =
     label: "item",
     context: loader.cm.PredicateContext(function (data) {
       assert.strictEqual(data.targetName, "input");
       return true;
     })
   })];
 
   test.withTestDoc(function (window, doc) {
-    test.showMenu(doc.getElementById("button"), function (popup) {
+    test.showMenu("#button", function (popup) {
       test.checkMenu(items, [], []);
       test.done();
     });
   });
 };
 
 
 // Test that the data object has the correct ID
@@ -3305,17 +3334,17 @@ exports.testPredicateContextTargetIDSet 
     label: "item",
     context: loader.cm.PredicateContext(function (data) {
       assert.strictEqual(data.targetID, "button");
       return true;
     })
   })];
 
   test.withTestDoc(function (window, doc) {
-    test.showMenu(doc.getElementById("button"), function (popup) {
+    test.showMenu("#button", function (popup) {
       test.checkMenu(items, [], []);
       test.done();
     });
   });
 };
 
 // Test that the data object has the correct ID
 exports.testPredicateContextTargetIDNotSet = function (assert, done) {
@@ -3326,17 +3355,17 @@ exports.testPredicateContextTargetIDNotS
     label: "item",
     context: loader.cm.PredicateContext(function (data) {
       assert.strictEqual(data.targetID, null);
       return true;
     })
   })];
 
   test.withTestDoc(function (window, doc) {
-    test.showMenu(doc.getElementsByClassName("predicate-test-a")[0], function (popup) {
+    test.showMenu(".predicate-test-a", function (popup) {
       test.checkMenu(items, [], []);
       test.done();
     });
   });
 };
 
 // Test that the data object is showing editable correctly for regular text inputs
 exports.testPredicateContextTextBoxIsEditable = function (assert, done) {
@@ -3347,17 +3376,17 @@ exports.testPredicateContextTextBoxIsEdi
     label: "item",
     context: loader.cm.PredicateContext(function (data) {
       assert.strictEqual(data.isEditable, true);
       return true;
     })
   })];
 
   test.withTestDoc(function (window, doc) {
-    test.showMenu(doc.getElementById("textbox"), function (popup) {
+    test.showMenu("#textbox", function (popup) {
       test.checkMenu(items, [], []);
       test.done();
     });
   });
 };
 
 // Test that the data object is showing editable correctly for readonly text inputs
 exports.testPredicateContextReadonlyTextBoxIsNotEditable = function (assert, done) {
@@ -3368,17 +3397,17 @@ exports.testPredicateContextReadonlyText
     label: "item",
     context: loader.cm.PredicateContext(function (data) {
       assert.strictEqual(data.isEditable, false);
       return true;
     })
   })];
 
   test.withTestDoc(function (window, doc) {
-    test.showMenu(doc.getElementById("readonly-textbox"), function (popup) {
+    test.showMenu("#readonly-textbox", function (popup) {
       test.checkMenu(items, [], []);
       test.done();
     });
   });
 };
 
 // Test that the data object is showing editable correctly for disabled text inputs
 exports.testPredicateContextDisabledTextBoxIsNotEditable = function (assert, done) {
@@ -3389,17 +3418,17 @@ exports.testPredicateContextDisabledText
     label: "item",
     context: loader.cm.PredicateContext(function (data) {
       assert.strictEqual(data.isEditable, false);
       return true;
     })
   })];
 
   test.withTestDoc(function (window, doc) {
-    test.showMenu(doc.getElementById("disabled-textbox"), function (popup) {
+    test.showMenu("#disabled-textbox", function (popup) {
       test.checkMenu(items, [], []);
       test.done();
     });
   });
 };
 
 // Test that the data object is showing editable correctly for text areas
 exports.testPredicateContextTextAreaIsEditable = function (assert, done) {
@@ -3410,17 +3439,17 @@ exports.testPredicateContextTextAreaIsEd
     label: "item",
     context: loader.cm.PredicateContext(function (data) {
       assert.strictEqual(data.isEditable, true);
       return true;
     })
   })];
 
   test.withTestDoc(function (window, doc) {
-    test.showMenu(doc.getElementById("textfield"), function (popup) {
+    test.showMenu("#textfield", function (popup) {
       test.checkMenu(items, [], []);
       test.done();
     });
   });
 };
 
 // Test that non-text inputs are not considered editable
 exports.testPredicateContextButtonIsNotEditable = function (assert, done) {
@@ -3431,17 +3460,17 @@ exports.testPredicateContextButtonIsNotE
     label: "item",
     context: loader.cm.PredicateContext(function (data) {
       assert.strictEqual(data.isEditable, false);
       return true;
     })
   })];
 
   test.withTestDoc(function (window, doc) {
-    test.showMenu(doc.getElementById("button"), function (popup) {
+    test.showMenu("#button", function (popup) {
       test.checkMenu(items, [], []);
       test.done();
     });
   });
 };
 
 
 // Test that the data object is showing editable correctly
@@ -3453,17 +3482,17 @@ exports.testPredicateContextNonInputIsNo
     label: "item",
     context: loader.cm.PredicateContext(function (data) {
       assert.strictEqual(data.isEditable, false);
       return true;
     })
   })];
 
   test.withTestDoc(function (window, doc) {
-    test.showMenu(doc.getElementById("image"), function (popup) {
+    test.showMenu("#image", function (popup) {
       test.checkMenu(items, [], []);
       test.done();
     });
   });
 };
 
 
 // Test that the data object is showing editable correctly for HTML contenteditable elements
@@ -3475,17 +3504,17 @@ exports.testPredicateContextEditableElem
     label: "item",
     context: loader.cm.PredicateContext(function (data) {
       assert.strictEqual(data.isEditable, true);
       return true;
     })
   })];
 
   test.withTestDoc(function (window, doc) {
-    test.showMenu(doc.getElementById("editable"), function (popup) {
+    test.showMenu("#editable", function (popup) {
       test.checkMenu(items, [], []);
       test.done();
     });
   });
 };
 
 
 // Test that the data object does not have a selection when there is none
@@ -3544,19 +3573,18 @@ exports.testPredicateContextSelectionInT
       // since we might get whitespace
       assert.strictEqual(data.selectionText, "t v");
       return true;
     })
   })];
 
   test.withTestDoc(function (window, doc) {
     let textbox = doc.getElementById("textbox");
-    textbox.focus();
-    textbox.setSelectionRange(3, 6);
-    test.showMenu(textbox, function (popup) {
+    test.selectRange("#textbox", 3, 6);
+    test.showMenu("#textbox", function (popup) {
       test.checkMenu(items, [], []);
       test.done();
     });
   });
 };
 
 // Test that the data object has the correct src for an image
 exports.testPredicateContextTargetSrcSet = function (assert, done) {
@@ -3569,17 +3597,17 @@ exports.testPredicateContextTargetSrcSet
     context: loader.cm.PredicateContext(function (data) {
       assert.strictEqual(data.srcURL, image.src);
       return true;
     })
   })];
 
   test.withTestDoc(function (window, doc) {
     image = doc.getElementById("image");
-    test.showMenu(image, function (popup) {
+    test.showMenu("#image", function (popup) {
       test.checkMenu(items, [], []);
       test.done();
     });
   });
 };
 
 // Test that the data object has no src for a link
 exports.testPredicateContextTargetSrcNotSet = function (assert, done) {
@@ -3590,17 +3618,17 @@ exports.testPredicateContextTargetSrcNot
     label: "item",
     context: loader.cm.PredicateContext(function (data) {
       assert.strictEqual(data.srcURL, null);
       return true;
     })
   })];
 
   test.withTestDoc(function (window, doc) {
-    test.showMenu(doc.getElementById("link"), function (popup) {
+    test.showMenu("#link", function (popup) {
       test.checkMenu(items, [], []);
       test.done();
     });
   });
 };
 
 
 // Test that the data object has the correct link set
@@ -3613,17 +3641,17 @@ exports.testPredicateContextTargetLinkSe
     label: "item",
     context: loader.cm.PredicateContext(function (data) {
       assert.strictEqual(data.linkURL, TEST_DOC_URL + "#test");
       return true;
     })
   })];
 
   test.withTestDoc(function (window, doc) {
-    test.showMenu(doc.getElementsByClassName("predicate-test-a")[0], function (popup) {
+    test.showMenu(".predicate-test-a", function (popup) {
       test.checkMenu(items, [], []);
       test.done();
     });
   });
 };
 
 // Test that the data object has no link for an image
 exports.testPredicateContextTargetLinkNotSet = function (assert, done) {
@@ -3634,17 +3662,17 @@ exports.testPredicateContextTargetLinkNo
     label: "item",
     context: loader.cm.PredicateContext(function (data) {
       assert.strictEqual(data.linkURL, null);
       return true;
     })
   })];
 
   test.withTestDoc(function (window, doc) {
-    test.showMenu(doc.getElementById("image"), function (popup) {
+    test.showMenu("#image", function (popup) {
       test.checkMenu(items, [], []);
       test.done();
     });
   });
 };
 
 // Test that the data object has the correct link for a nested image
 exports.testPredicateContextTargetLinkSetNestedImage = function (assert, done) {
@@ -3655,17 +3683,17 @@ exports.testPredicateContextTargetLinkSe
     label: "item",
     context: loader.cm.PredicateContext(function (data) {
       assert.strictEqual(data.linkURL, TEST_DOC_URL + "#nested-image");
       return true;
     })
   })];
 
   test.withTestDoc(function (window, doc) {
-    test.showMenu(doc.getElementById("predicate-test-nested-image"), function (popup) {
+    test.showMenu("#predicate-test-nested-image", function (popup) {
       test.checkMenu(items, [], []);
       test.done();
     });
   });
 };
 
 // Test that the data object has the correct link for a complex nested structure
 exports.testPredicateContextTargetLinkSetNestedStructure = function (assert, done) {
@@ -3676,17 +3704,17 @@ exports.testPredicateContextTargetLinkSe
     label: "item",
     context: loader.cm.PredicateContext(function (data) {
       assert.strictEqual(data.linkURL, TEST_DOC_URL + "#nested-structure");
       return true;
     })
   })];
 
   test.withTestDoc(function (window, doc) {
-    test.showMenu(doc.getElementById("predicate-test-nested-structure"), function (popup) {
+    test.showMenu("#predicate-test-nested-structure", function (popup) {
       test.checkMenu(items, [], []);
       test.done();
     });
   });
 };
 
 // Test that the data object has the value for an input textbox
 exports.testPredicateContextTargetValueSet = function (assert, done) {
@@ -3698,17 +3726,17 @@ exports.testPredicateContextTargetValueS
     label: "item",
     context: loader.cm.PredicateContext(function (data) {
       assert.strictEqual(data.value, "test value");
       return true;
     })
   })];
 
   test.withTestDoc(function (window, doc) {
-    test.showMenu(doc.getElementById("textbox"), function (popup) {
+    test.showMenu("#textbox", function (popup) {
       test.checkMenu(items, [], []);
       test.done();
     });
   });
 };
 
 // Test that the data object has no value for an image
 exports.testPredicateContextTargetValueNotSet = function (assert, done) {
@@ -3719,17 +3747,17 @@ exports.testPredicateContextTargetValueN
     label: "item",
     context: loader.cm.PredicateContext(function (data) {
       assert.strictEqual(data.value, null);
       return true;
     })
   })];
 
   test.withTestDoc(function (window, doc) {
-    test.showMenu(doc.getElementById("image"), function (popup) {
+    test.showMenu("#image", function (popup) {
       test.checkMenu(items, [], []);
       test.done();
     });
   });
 };
 
 
 // NO TESTS BELOW THIS LINE! ///////////////////////////////////////////////////
@@ -4093,98 +4121,162 @@ TestHelper.prototype = {
   shouldOverflow: function (count) {
     return count >
            (this.loaders.length ?
             this.loaders[0].loader.require("sdk/preferences/service").
               get(OVERFLOW_THRESH_PREF, OVERFLOW_THRESH_DEFAULT) :
             OVERFLOW_THRESH_DEFAULT);
   },
 
-  // Opens the context menu on the current page.  If targetNode is null, the
+  // Loads scripts necessary in the content process
+  loadFrameScript: function(browser = this.browserWindow.gBrowser.selectedBrowser) {
+    function frame_script() {
+      let { interfaces: Ci } = Components;
+      addMessageListener('test:contextmenu', ({ data: { selectors } }) => {
+        let targetNode = null;
+        let contentWin = content;
+        if (selectors) {
+          while (selectors.length) {
+            targetNode = contentWin.document.querySelector(selectors.shift());
+            if (selectors.length)
+              contentWin = targetNode.contentWindow;
+          }
+        }
+
+        let rect = targetNode ?
+                   targetNode.getBoundingClientRect() :
+                   { left: 0, top: 0, width: 0, height: 0 };
+        contentWin.QueryInterface(Ci.nsIInterfaceRequestor)
+                  .getInterface(Ci.nsIDOMWindowUtils)
+                  .sendMouseEvent('contextmenu',
+                  rect.left + (rect.width / 2),
+                  rect.top + (rect.height / 2),
+                  2, 1, 0);
+      });
+
+      addMessageListener('test:ping', () => {
+        sendAsyncMessage('test:pong');
+      });
+
+      addMessageListener('test:select', ({ data: { selector, start, end } }) => {
+        let element = content.document.querySelector(selector);
+        element.focus();
+        if (end === null)
+          end = element.value.length;
+        element.setSelectionRange(start, end);
+      });
+    }
+
+    let messageManager = browser.messageManager;
+    messageManager.loadFrameScript("data:,(" + frame_script.toString() + ")();", true);
+  },
+
+  selectRange: function(selector, start, end) {
+    let messageManager = this.browserWindow.gBrowser.selectedBrowser.messageManager;
+    messageManager.sendAsyncMessage('test:select', { selector, start, end });
+  },
+
+  // Opens the context menu on the current page.  If selectors is null, the
   // menu is opened in the top-left corner.  onShowncallback is passed the
-  // popup.
-  showMenu: function(targetNode, onshownCallback) {
+  // popup. selectors is an array of selectors. Starting from the main document
+  // each selector points to an iframe, the last selector gives the target node.
+  // In the simple case of a single selector just that string can be passed
+  // instead of an array
+  showMenu: function(selectors, onshownCallback) {
     let { promise, resolve } = defer();
 
-    function sendEvent() {
-      this.delayedEventListener(this.browserWindow, "popupshowing",
+    if (selectors && !Array.isArray(selectors))
+      selectors = [selectors];
+
+    let sendEvent = () => {
+      let menu = this.browserWindow.document.getElementById("contentAreaContextMenu");
+      this.delayedEventListener(menu, "popupshowing",
         function (e) {
           let popup = e.target;
           if (onshownCallback) {
             onshownCallback.call(this, popup);
           }
           resolve(popup);
         }, false);
 
-      let rect = targetNode ?
-                 targetNode.getBoundingClientRect() :
-                 { left: 0, top: 0, width: 0, height: 0 };
-      let contentWin = targetNode ? targetNode.ownerDocument.defaultView
-                                  : this.browserWindow.content;
-      contentWin.
-        QueryInterface(Ci.nsIInterfaceRequestor).
-        getInterface(Ci.nsIDOMWindowUtils).
-        sendMouseEvent("contextmenu",
-                       rect.left + (rect.width / 2),
-                       rect.top + (rect.height / 2),
-                       2, 1, 0);
+      let messageManager = this.browserWindow.gBrowser.selectedBrowser.messageManager;
+      messageManager.sendAsyncMessage('test:contextmenu', { selectors });
+    }
+
+    // Bounces an asynchronous message through the browser message manager.
+    // This ensures that any pending messages have been delivered to the frame
+    // scripts and so the remote proxies have been updated
+    let flushMessages = () => {
+      let listener = () => {
+        messageManager.removeMessageListener('test:pong', listener);
+        sendEvent();
+      };
+
+      let messageManager = this.browserWindow.gBrowser.selectedBrowser.messageManager;
+      messageManager.addMessageListener('test:pong', listener);
+      messageManager.sendAsyncMessage('test:ping');
     }
 
     // If a new tab or window has not yet been opened, open a new tab now.  For
     // some reason using the tab already opened when the test starts causes
     // leaks.  See bug 566351 for details.
-    if (!targetNode && !this.oldSelectedTab && !this.oldBrowserWindow) {
+    if (!selectors && !this.oldSelectedTab && !this.oldBrowserWindow) {
       this.oldSelectedTab = this.tabBrowser.selectedTab;
       this.tab = this.tabBrowser.addTab("about:blank");
       let browser = this.tabBrowser.getBrowserForTab(this.tab);
 
       this.delayedEventListener(browser, "load", function () {
         this.tabBrowser.selectedTab = this.tab;
-        sendEvent.call(this);
+        this.loadFrameScript();
+        flushMessages();
       }, true);
     }
-    else
-      sendEvent.call(this);
+    else {
+      flushMessages();
+    }
 
     return promise;
   },
 
   hideMenu: function(onhiddenCallback) {
     this.delayedEventListener(this.browserWindow, "popuphidden", onhiddenCallback);
 
     this.contextMenuPopup.hidePopup();
   },
 
   // Opens a new browser window.  The window will be closed automatically when
   // done() is called.
-  withNewWindow: function (onloadCallback) {
-    let win = this.browserWindow.OpenBrowserWindow();
-    this.delayedEventListener(win, "load", onloadCallback, true);
+  withNewWindow: function (onloadCallback, makePrivate = false) {
+    let win = this.browserWindow.OpenBrowserWindow({ private: makePrivate });
+    observers.once("browser-delayed-startup-finished", () => {
+      // Open a new tab so we can make sure it is remote and loaded
+      win.gBrowser.selectedTab = win.gBrowser.addTab();
+      this.loadFrameScript();
+      this.delayedEventListener(win.gBrowser.selectedBrowser, "load", onloadCallback, true);
+    });
     this.oldBrowserWindow = this.browserWindow;
     this.browserWindow = win;
   },
 
   // Opens a new private browser window.  The window will be closed
   // automatically when done() is called.
   withNewPrivateWindow: function (onloadCallback) {
-    let win = this.browserWindow.OpenBrowserWindow({private: true});
-    this.delayedEventListener(win, "load", onloadCallback, true);
-    this.oldBrowserWindow = this.browserWindow;
-    this.browserWindow = win;
+    this.withNewWindow(onloadCallback, true);
   },
 
   // Opens a new tab with our test page in the current window.  The tab will
   // be closed automatically when done() is called.
   withTestDoc: function (onloadCallback) {
     this.oldSelectedTab = this.tabBrowser.selectedTab;
     this.tab = this.tabBrowser.addTab(TEST_DOC_URL);
     let browser = this.tabBrowser.getBrowserForTab(this.tab);
 
     this.delayedEventListener(browser, "load", function () {
       this.tabBrowser.selectedTab = this.tab;
+      this.loadFrameScript();
       onloadCallback.call(this, browser.contentWindow, browser.contentDocument);
     }, true, function(evt) {
       return evt.target.location == TEST_DOC_URL;
     });
   }
 };
 
 require('sdk/test').run(exports);