Bug 1166365 - Add tests narrator tests. draft
authorEitan Isaacson <eitan@monotonous.org>
Mon, 01 Feb 2016 11:12:03 -0800
changeset 331586 7621866322296c8de835448c64983d2d48a18c2a
parent 331585 33ea61473404d0b6127698161a505432e45578a6
child 514415 9a221ca7caf61dccba9bbdf3af2f453d72f18c9a
push id11019
push userbmo:eitan@monotonous.org
push dateWed, 17 Feb 2016 17:54:47 +0000
bugs1166365
milestone47.0a1
Bug 1166365 - Add tests narrator tests. MozReview-Commit-ID: NKo8xwiKzK
browser/extensions/narrate/.eslintrc
browser/extensions/narrate/content/Narrator.jsm
browser/extensions/narrate/moz.build
browser/extensions/narrate/test/browser.ini
browser/extensions/narrate/test/browser_narrate.js
browser/extensions/narrate/test/browser_narrate_disable.js
browser/extensions/narrate/test/browser_voiceselect.js
browser/extensions/narrate/test/content_narrate.js
browser/extensions/narrate/test/head.js
--- a/browser/extensions/narrate/.eslintrc
+++ b/browser/extensions/narrate/.eslintrc
@@ -11,16 +11,18 @@
 
   "env": { "browser": true },
 
   "rules": {
     // Mozilla stuff
     "mozilla/no-aArgs": 1,
     "mozilla/reject-importGlobalProperties": 1,
     "mozilla/var-only-at-top-level": 1,
+    "mozilla/no-cpows-in-tests": 0,
+    "mozilla/import-headjs-globals": 1,
 
     "block-scoped-var": 2,
     "brace-style": [1, "1tbs", {"allowSingleLine": false}],
     "camelcase": 1,
     "comma-dangle": 1,
     "comma-spacing": [1, {"before": false, "after": true}],
     "comma-style": [1, "last"],
     "complexity": 1,
--- a/browser/extensions/narrate/content/Narrator.jsm
+++ b/browser/extensions/narrate/content/Narrator.jsm
@@ -5,21 +5,24 @@
 "use strict";
 
 const { interfaces: Ci, utils: Cu } = Components;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "LanguageDetector",
   "resource:///modules/translation/LanguageDetector.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Services",
+  "resource://gre/modules/Services.jsm");
 
 this.EXPORTED_SYMBOLS = [ "Narrator" ];
 
 function Narrator(win) {
   this._winRef = Cu.getWeakReference(win);
+  this._inTest = Services.prefs.getBoolPref("extensions.narrate.test");
   this._speechOptions = {};
   this._startTime = 0;
   this._stopped = false;
 }
 
 Narrator.prototype = {
   get _doc() {
     return this._winRef.get().document;
@@ -115,26 +118,38 @@ Narrator.prototype = {
 
     return new Promise(resolve => {
       utterance.addEventListener("start", () => {
         paragraph.classList.add("narrating");
         let bb = paragraph.getBoundingClientRect();
         if (bb.top < 0 || bb.bottom > this._win.innerHeight) {
           paragraph.scrollIntoView({ behavior: "smooth", block: "start"});
         }
+        if (this._inTest) {
+          Services.cpmm.sendAsyncMessage("Narrate:StartParagraph",
+            { voice: utterance.chosenVoiceURI,
+              rate: utterance.rate,
+              paragraph: this._index });
+        }
       });
 
       utterance.addEventListener("end", () => {
         if (!this._win) {
           // page got unloaded, don't do anything.
           return;
         }
 
         paragraph.classList.remove("narrating");
         this._startTime = 0;
+        if (this._inTest) {
+          Services.cpmm.sendAsyncMessage("Narrate:EndParagraph",
+            { voice: utterance.chosenVoiceURI,
+              rate: utterance.rate,
+              paragraph: this._index });
+        }
         if (this._index + 1 >= this._paragraphs.length || this._stopped) {
           // We reached the end of the document, or the user pressed stopped.
           resolve();
         } else {
           this._index++;
           this._speakInner().then(resolve);
         }
       });
--- a/browser/extensions/narrate/moz.build
+++ b/browser/extensions/narrate/moz.build
@@ -9,9 +9,11 @@ DIRS += ['locales']
 FINAL_TARGET_FILES.features['narrate@mozilla.org'] += [
   'bootstrap.js'
 ]
 
 FINAL_TARGET_PP_FILES.features['narrate@mozilla.org'] += [
   'install.rdf.in'
 ]
 
+BROWSER_CHROME_MANIFESTS += ['test/browser.ini']
+
 JAR_MANIFESTS += ['jar.mn']
new file mode 100644
--- /dev/null
+++ b/browser/extensions/narrate/test/browser.ini
@@ -0,0 +1,8 @@
+[DEFAULT]
+support-files =
+  head.js
+  content_narrate.js
+
+[browser_narrate.js]
+[browser_narrate_disable.js]
+[browser_voiceselect.js]
new file mode 100644
--- /dev/null
+++ b/browser/extensions/narrate/test/browser_narrate.js
@@ -0,0 +1,83 @@
+/* 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/. */
+
+/* globals is, isnot, registerCleanupFunction, add_task */
+
+"use strict";
+
+const TEST_PATH = "http://example.com/browser/browser/base/content/test/general/";
+const TEST_VOICE = "urn:moz-tts:fake-indirect:teresa";
+
+add_task(function* testNarrate() {
+  registerCleanupFunction(teardown);
+
+  setup();
+
+  let tab = yield enterReaderMode(TEST_PATH + "readerModeArticle.html");
+
+  // Narrate button is showing
+  yield content.isVisible(tab, ELEMENT.TOGGLE, "narrate button is there");
+
+  // Popup is hidden by default
+  yield content.isHidden(tab, ELEMENT.POPUP, "popup is hidden");
+
+  // Click button to show popup
+  yield content.click(tab, ELEMENT.TOGGLE, "click narrate toggle");
+
+  // Popup is shown
+  yield content.isVisible(tab, ELEMENT.POPUP, "popup is showing");
+
+  // Select widget is showing, but options are not.
+  yield content.isVisible(tab, ELEMENT.VOICE_SELECT, "select button showing");
+  yield selectVoice(tab, TEST_VOICE);
+
+  yield isStoppedState(tab);
+  let speechinfo = yield content.narrateStart(tab, "start narrate", () => {
+    content.click(tab, ELEMENT.START, "start button pressed");
+  });
+  is(speechinfo.voice, TEST_VOICE, "correct voice is used");
+  is(speechinfo.paragraph, 0, "first paragraph is being spoken");
+  yield isNarratingState(tab);
+
+  speechinfo = yield content.narrateStart(tab,
+    "narrate next paragraph", () => {
+      content.click(tab, ELEMENT.FORWARD, "forward button pressed");
+    });
+  is(speechinfo.voice, TEST_VOICE, "correct voice is used");
+  is(speechinfo.paragraph, 1, "second paragraph is being spoken");
+
+  let newspeechinfo = yield content.narrateStart(tab,
+    "adjust rate and start paragraph again", () => {
+      content.sendkey(tab, ELEMENT.RATE, "PAGE_UP", "rate change");
+    });
+  is(newspeechinfo.paragraph, speechinfo.paragraph, "restart same paragraph");
+  isnot(newspeechinfo.rate, speechinfo.rate, "rate changed");
+
+  yield content.narrateStop(tab, "stop narrate", () => {
+    content.click(tab, ELEMENT.STOP, "start button pressed");
+  });
+  yield isStoppedState(tab);
+
+  newspeechinfo = yield content.narrateStart(tab,
+    "start narrate again", () => {
+      content.click(tab, ELEMENT.START, "start button pressed");
+    });
+  yield isNarratingState(tab);
+  is(newspeechinfo.paragraph, speechinfo.paragraph,
+    "start from last paragraph");
+
+  yield content.scroll(tab, 0, 10);
+  yield content.isVisible(tab, ELEMENT.POPUP, "popup is visible");
+
+  yield content.narrateStop(tab, "closing popup stops speech", () => {
+    content.click(tab, ELEMENT.TOGGLE, "narrate popu closed");
+  });
+  yield content.isHidden(tab, ELEMENT.POPUP, "popup is hidden");
+  yield content.click(tab, ELEMENT.TOGGLE, "click narrate toggle");
+  yield isStoppedState(tab);
+
+  yield content.isVisible(tab, ELEMENT.POPUP, "popup is visible");
+  yield content.scroll(tab, 0, -10);
+  yield content.isHidden(tab, ELEMENT.POPUP, "popup is hidden");
+});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/narrate/test/browser_narrate_disable.js
@@ -0,0 +1,69 @@
+/* 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/. */
+
+/* globals registerCleanupFunction, add_task */
+
+"use strict";
+
+const TEST_PATH = "http://example.com/browser/browser/base/content/test/general/";
+const ENABLE_PREF = "extensions.narrate.enabled";
+
+add_task(function* testNarratePref() {
+  registerCleanupFunction(() => {
+    clearUserPref(ENABLE_PREF);
+    teardown();
+  });
+
+  setup();
+
+  let tab = yield enterReaderMode(TEST_PATH + "readerModeArticle.html");
+
+  yield content.isVisible(tab, ELEMENT.TOGGLE, "narrate button is there");
+
+  setBoolPref(ENABLE_PREF, false);
+
+  yield content.isVisible(tab, ELEMENT.TOGGLE, "narrate button is not here");
+
+  let tab2 = yield enterReaderMode(TEST_PATH + "readerModeArticle.html");
+
+  yield content.isHidden(tab2, ELEMENT.TOGGLE, "narrate button is not here");
+
+  setBoolPref(ENABLE_PREF, true);
+
+  yield content.isVisible(tab, ELEMENT.TOGGLE, "narrate button is there");
+
+  yield content.isHidden(tab2, ELEMENT.TOGGLE, "narrate button is there");
+
+  let tab3 = yield enterReaderMode(TEST_PATH + "readerModeArticle.html");
+
+  yield content.isVisible(tab3, ELEMENT.TOGGLE, "narrate button is there");
+});
+
+add_task(function* testNarrateAddon() {
+  registerCleanupFunction(teardown);
+
+  setup();
+
+  let tab = yield enterReaderMode(TEST_PATH + "readerModeArticle.html");
+
+  yield content.isVisible(tab, ELEMENT.TOGGLE, "narrate button is there");
+
+  yield toggleExtension(false);
+
+  yield content.isVisible(tab, ELEMENT.TOGGLE, "narrate button is not here");
+
+  let tab2 = yield enterReaderMode(TEST_PATH + "readerModeArticle.html");
+
+  yield content.isHidden(tab2, ELEMENT.TOGGLE, "narrate button is not here");
+
+  yield toggleExtension(true);
+
+  yield content.isVisible(tab, ELEMENT.TOGGLE, "narrate button is there");
+
+  yield content.isHidden(tab2, ELEMENT.TOGGLE, "narrate button is there");
+
+  let tab3 = yield enterReaderMode(TEST_PATH + "readerModeArticle.html");
+
+  yield content.isVisible(tab3, ELEMENT.TOGGLE, "narrate button is there");
+});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/narrate/test/browser_voiceselect.js
@@ -0,0 +1,97 @@
+/* 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/. */
+
+/* globals registerCleanupFunction, add_task, is, isnot */
+
+"use strict";
+
+const TEST_PATH = "http://example.com/browser/browser/base/content/test/general/";
+const VOICE_1 = {
+  uri: "urn:moz-tts:fake-direct:lenny",
+  label: "Leonard Cohen (en-CA)"
+};
+
+const VOICE_SELECT_LABEL = "#narrate-voices .select-toggle .current-voice";
+
+add_task(function* testVoiceselectDropdownAutoclose() {
+  registerCleanupFunction(teardown);
+
+  setup();
+
+  let tab = yield enterReaderMode(TEST_PATH + "readerModeArticle.html");
+
+  // Click button to show popup
+  yield content.click(tab, ELEMENT.TOGGLE, "click narrate toggle");
+
+  yield content.isHidden(tab, ELEMENT.VOICE_OPTIONS, "options are hidden");
+
+  yield content.click(tab, ELEMENT.VOICE_SELECT, "click select widget");
+
+  yield content.isVisible(tab, ELEMENT.VOICE_OPTIONS, "options are showing");
+
+  yield content.click(tab, ELEMENT.TOGGLE, "close popup");
+
+  yield content.isHidden(tab, ELEMENT.POPUP, "popup is hidden");
+
+  yield content.click(tab, ELEMENT.TOGGLE, "open popup");
+  yield content.isVisible(tab, ELEMENT.POPUP, "popup is hidden");
+
+  // When popup re-opens voices dropdown should be closed.
+  yield content.isHidden(tab, ELEMENT.VOICE_OPTIONS, "options are hidden");
+});
+
+add_task(function* testVoiceselectLabelChange() {
+  registerCleanupFunction(teardown);
+
+  setup();
+
+  let tab = yield enterReaderMode(TEST_PATH + "readerModeArticle.html");
+
+  // Click button to show popup
+  yield content.click(tab, ELEMENT.TOGGLE, "click narrate toggle");
+
+  yield selectVoice(tab, VOICE_1.uri);
+
+  let label = yield content.getText(tab, VOICE_SELECT_LABEL);
+  is(label, VOICE_1.label);
+});
+
+add_task(function* testVoiceselectKeyboard() {
+  registerCleanupFunction(teardown);
+
+  setup();
+
+  let tab = yield enterReaderMode(TEST_PATH + "readerModeArticle.html");
+
+  // Click button to show popup
+  yield content.click(tab, ELEMENT.TOGGLE, "click narrate toggle");
+
+  let firstValue = yield content.getValue(tab, ELEMENT.VOICE_SELECTED);
+
+  yield content.sendkey(tab, ELEMENT.VOICE_SELECT, "DOWN", "change voice");
+
+  let secondValue = yield content.getValue(tab, ELEMENT.VOICE_SELECTED);
+
+  isnot(firstValue, secondValue, "value changed after arrowing in button");
+
+  yield content.isHidden(tab, ELEMENT.VOICE_OPTIONS, "option hidden 1");
+
+  yield content.sendkey(tab, ELEMENT.VOICE_SELECT, "RETURN", "open popup");
+
+  yield content.isVisible(tab, ELEMENT.VOICE_OPTIONS, "options visible");
+
+  yield content.sendkey(tab, null, "UP", "go up one item");
+
+  let thirdValue = yield content.getValue(tab, ELEMENT.VOICE_SELECTED);
+
+  is(thirdValue, secondValue, "arrowing in options shouldn't change selection");
+
+  yield content.sendkey(tab, null, "RETURN", "select and close options");
+
+  yield content.isHidden(tab, ELEMENT.VOICE_OPTIONS, "option hidden 2");
+
+  let fourthValue = yield content.getValue(tab, ELEMENT.VOICE_SELECTED);
+
+  is(fourthValue, firstValue, "selection changes back to first value");
+});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/narrate/test/content_narrate.js
@@ -0,0 +1,132 @@
+/* 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/. */
+
+/* globals content, sendAsyncMessage, addMessageListener */
+
+// This file is loaded as a "content script" for browser narrate tests
+
+"use strict";
+
+var {interfaces: Ci, utils: Cu, classes: Cc} = Components;
+
+Cu.import("resource://gre/modules/Services.jsm");
+var EventUtils = {
+  "_EU_Ci": Ci,
+  "_EU_Cc": Cc,
+
+  get window() {
+    return content;
+  },
+
+  get parent() {
+    return content;
+  },
+
+  get KeyboardEvent() {
+    return content.KeyboardEvent;
+  },
+
+  get navigator() {
+    return content.navigator;
+  },
+
+  get KeyEvent() {
+    return content.KeyEvent;
+  }
+};
+Services.scriptloader.loadSubScript("chrome://mochikit/content/tests/SimpleTest/EventUtils.js", EventUtils);
+
+function observe(subject, topic) {
+  sendAsyncMessage("test:document:load");
+  Services.obs.removeObserver(observe, topic);
+}
+
+Services.obs.addObserver(observe, "AboutReader:Ready", false);
+
+function _msgHandler(msg, cb, failIfNotFound = true) {
+  // if no selector is provided, use the focused element
+  let element = msg.data.selector ?
+    content.document.querySelector(msg.data.selector) :
+    content.document.activeElement;
+  let responseName = msg.data.responseName || (msg.name + ":response");
+  if (!element && failIfNotFound) {
+    sendAsyncMessage(responseName, { msg: "No element found" });
+  } else {
+    sendAsyncMessage(responseName, { result: cb(element) });
+  }
+}
+
+addMessageListener("test:element-visible", msg => {
+  _msgHandler(msg, e => !!(e && isVisible(e)), false);
+});
+
+addMessageListener("test:element-selected", msg => {
+  _msgHandler(msg,
+    e => e.selected || JSON.parse(e.getAttribute("aria-selected") || "false"));
+});
+
+addMessageListener("test:element-enabled", msg => {
+  _msgHandler(msg, e => !e.disabled);
+});
+
+addMessageListener("test:element-click", msg => {
+  _msgHandler(msg, e => {
+    e.click();
+    return true;
+  });
+});
+
+addMessageListener("test:element-focus", msg => {
+  _msgHandler(msg, e => {
+    e.focus();
+    return true;
+  });
+});
+
+addMessageListener("test:element-sendkey", msg => {
+  _msgHandler(msg, e => {
+    e.focus();
+    EventUtils.sendKey(msg.data.key, content);
+    return true;
+  });
+});
+
+addMessageListener("test:element-get-text", msg => {
+  _msgHandler(msg, e => {
+    return e.textContent;
+  });
+});
+
+addMessageListener("test:element-get-value", msg => {
+  _msgHandler(msg, e => {
+    return e.value || e.dataset.value;
+  });
+});
+
+addMessageListener("test:scroll", msg => {
+  function sendResponse() {
+    sendAsyncMessage(msg.data.responseName, { });
+    content.removeEventListener("scroll", sendResponse);
+  }
+  content.addEventListener("scroll", sendResponse);
+  content.scrollBy(msg.data.x, msg.data.y);
+});
+
+function isVisible(element) {
+  let style = element.ownerDocument.defaultView.getComputedStyle(element, "");
+  if (style.display == "none") {
+    return false;
+  } else if (style.visibility != "visible") {
+    return false;
+  } else if (style.display == "-moz-popup" && element.state != "open") {
+    return false;
+  }
+
+  // Hiding a parent element will hide all its children
+  if (element.parentNode != element.ownerDocument) {
+    return isVisible(element.parentNode);
+  }
+
+  return true;
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/narrate/test/head.js
@@ -0,0 +1,237 @@
+/* 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/. */
+
+/* globals ok, isnot, gBrowser */
+/* exported ELEMENT, isStoppedState, isNarratingState, selectVoice,
+   enterReaderMode, content, teardown, setup, toggleExtension  */
+
+"use strict";
+
+const ELEMENT = {
+  TOGGLE: "#narrate-toggle",
+  POPUP: "#narrate-dropdown .dropdown-popup",
+  VOICE_SELECT: "#narrate-voices .select-toggle",
+  VOICE_OPTIONS: "#narrate-voices .options",
+  VOICE_SELECTED: "#narrate-voices .options .option.selected",
+  RATE: "#narrate-rate-input",
+  START: "#narrate-start-stop:not(.speaking)",
+  STOP: "#narrate-start-stop.speaking",
+  BACK: "#narrate-skip-previous",
+  FORWARD: "#narrate-skip-next"
+};
+
+Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "Promise",
+  "resource://gre/modules/Promise.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Services",
+  "resource://gre/modules/Services.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
+  "resource://gre/modules/AddonManager.jsm");
+
+const TEST_PREFS = [
+  ["reader.parse-on-load.enabled", true],
+  ["media.webspeech.synth.test", true],
+  ["browser.reader.detectedFirstArticle", true],
+  ["extensions.narrate.test", true]
+];
+
+const CHROME_BASE =
+  "chrome://mochitests/content/browser/browser/extensions/narrate/test/";
+
+const ADDON_ID = "narrate@mozilla.org";
+
+function voiceOption(voiceUri) {
+  return '#narrate-voices .option[data-value="' + voiceUri + '"]';
+}
+
+function isStoppedState(tab) {
+  return Promise.all([
+    content.isDisabled(tab, ELEMENT.BACK, "back button is disabled"),
+    content.isDisabled(tab, ELEMENT.FORWARD, "forward button is disabled"),
+    content.isHidden(tab, ELEMENT.STOP, "stop button is hidden"),
+    content.isVisible(tab, ELEMENT.START, "start button is showing")]);
+}
+
+function isNarratingState(tab) {
+  return Promise.all([
+    content.isEnabled(tab, ELEMENT.BACK, "back button is enabled"),
+    content.isEnabled(tab, ELEMENT.FORWARD, "forward button is enabled"),
+    content.isVisible(tab, ELEMENT.STOP, "stop button is showing"),
+    content.isHidden(tab, ELEMENT.START, "start button is hidden")]);
+}
+
+function selectVoice(tab, voiceUri) {
+  return content.isHidden(tab, ELEMENT.VOICE_OPTIONS, "options hidden")
+    .then(
+      () => content.click(tab, ELEMENT.VOICE_SELECT, "click select widget"))
+    .then(
+      () => content.isVisible(tab, ELEMENT.VOICE_OPTIONS, "options visible"))
+    .then(
+      () => content.focus(tab, voiceOption(voiceUri), "focused option"))
+    .then(
+      () => content.click(tab, voiceOption(voiceUri), "select option"))
+    .then(
+      () => content.isHidden(tab, ELEMENT.VOICE_OPTIONS, "options hidden"))
+    .then(
+      () => content.isSelected(tab, voiceOption(voiceUri), "option selected"));
+}
+
+function setBoolPref(name, value) {
+  Services.prefs.setBoolPref(name, value);
+}
+
+function clearUserPref(name) {
+  Services.prefs.clearUserPref(name);
+}
+
+function setup() {
+  // Set required test prefs.
+  TEST_PREFS.forEach(([name, value]) => {
+    setBoolPref(name, value);
+  });
+}
+
+function enterReaderMode(url) {
+  return promiseNewTabLoadEvent(
+    "about:reader?url=" + encodeURIComponent(url),
+    CHROME_BASE + "content_narrate.js");
+}
+
+function promiseNewTabLoadEvent(url, frameScript) {
+  let tab = gBrowser.selectedTab = gBrowser.addTab(url);
+  let browser = tab.linkedBrowser;
+  let mm = browser.messageManager;
+
+  // give it an e10s-friendly content script to help with our tests.
+  mm.loadFrameScript(frameScript, true);
+  // and wait for it to tell us about the load.
+  return promiseOneMessage(tab, "test:document:load").then(
+    () => tab
+  );
+}
+
+function promiseOneMessage(tab, messageName) {
+  let mm = tab ? tab.linkedBrowser.messageManager : Services.ppmm;
+  let deferred = Promise.defer();
+  mm.addMessageListener(messageName, function onmessage(message) {
+    mm.removeMessageListener(messageName, onmessage);
+    deferred.resolve(message);
+  });
+  return deferred.promise;
+}
+
+function teardown() {
+  // Reset test prefs.
+  TEST_PREFS.forEach(pref => {
+    clearUserPref(pref[0]);
+  });
+  while (gBrowser.tabs.length > 1) {
+    gBrowser.removeCurrentTab();
+  }
+}
+
+function toggleExtension(enabled) {
+  let deferred = Promise.defer();
+  AddonManager.getAddonByID(ADDON_ID, addon => {
+    addon.userDisabled = !enabled;
+    deferred.resolve();
+  });
+  return deferred.promise;
+}
+
+function notOk(value, msg) {
+  ok(!value, msg);
+}
+
+var content = {
+  _generateResponseName: function(cmd) {
+    return "test:" + cmd + ":response:" + Math.random();
+  },
+
+  _do: function(tab, cmd, data, checkFunc, msg) {
+    let mm = tab.linkedBrowser.messageManager;
+    data.responseName = this._generateResponseName(cmd);
+    mm.sendAsyncMessage("test:" + cmd, data);
+    return promiseOneMessage(tab, data.responseName).then(
+      m => {
+        if (checkFunc) {
+          checkFunc(
+            m.data.result, msg + (m.data.msg ? " [" + m.data.msg + "]" : ""));
+        } else {
+          isnot(m.data.result, undefined, m.data.msg || cmd);
+        }
+        return m.data.result;
+      });
+  },
+
+  isVisible: function(tab, selector, msg) {
+    return this._do(tab, "element-visible", { selector: selector }, ok, msg);
+  },
+
+  isHidden: function(tab, selector, msg) {
+    return this._do(tab, "element-visible", { selector: selector }, notOk, msg);
+  },
+
+  click: function(tab, selector, msg) {
+    return this._do(tab, "element-click", { selector: selector }, ok, msg);
+  },
+
+  sendkey: function(tab, selector, key, msg) {
+    return this._do(tab, "element-sendkey",
+      { selector: selector, key: key }, ok, msg);
+  },
+
+  focus: function(tab, selector, msg) {
+    return this._do(tab, "element-focus", { selector: selector }, ok, msg);
+  },
+
+  isSelected: function(tab, selector, msg) {
+    return this._do(tab, "element-selected", { selector: selector }, ok, msg);
+  },
+
+  getValue: function(tab, selector, msg) {
+    return this._do(tab, "element-get-value", { selector: selector }, null,
+      msg);
+  },
+
+  isEnabled: function(tab, selector, msg) {
+    return this._do(tab, "element-enabled", { selector: selector }, ok, msg);
+  },
+
+  isDisabled: function(tab, selector, msg) {
+    return this._do(tab, "element-enabled", { selector: selector }, notOk, msg);
+  },
+
+  scroll: function(tab, x, y) {
+    let mm = tab.linkedBrowser.messageManager;
+    let responseName = this._generateResponseName("scroll");
+    mm.sendAsyncMessage("test:scroll",
+      { x: x, y: y, responseName: responseName });
+    return promiseOneMessage(tab, responseName);
+  },
+
+  getText: function(tab, selector, msg) {
+    return this._do(tab, "element-get-text", { selector: selector }, null,
+      msg);
+  },
+
+  narrateStart: function(tab, msg, func) {
+    let interact = func();
+    let startparagraph = promiseOneMessage(null, "Narrate:StartParagraph");
+    return Promise.all([interact, startparagraph]).then(args => {
+      ok(true, msg);
+      return args[1].data;
+    });
+  },
+
+  narrateStop: function(tab, msg, func) {
+    let interact = func();
+    let endparagraph = promiseOneMessage(null, "Narrate:EndParagraph");
+    return Promise.all([interact, endparagraph]).then(args => {
+      ok(true, msg);
+      return args[1].data;
+    });
+  }
+};