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 216249 872f2f8a6c0d9517fab6cfd3dae2f44a49cfea4e
parent 216248 cc48ff0ed5092319d1953dc15fc595c7ea0cbbbf
child 216250 a0149f5555a58ec4d272ffa2cc3ebaa8f1046652
push id51983
push usercbook@mozilla.com
push dateTue, 18 Nov 2014 16:32:13 +0000
treeherdermozilla-inbound@d1469442b5f7 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerszombie
bugs1060138
milestone36.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 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);