Bug 733631 - Create a unit test infrastructure for the webapp runtime. r=myk,felipc,ctalbert
authorDrew Willcoxon <adw@mozilla.com>
Fri, 29 Jun 2012 15:52:43 -0700
changeset 102795 df17f2f4ab63777c252b88b9e68211422a9c4501
parent 102794 b3d91c0e323ba37003151f55e41b27120e1b5060
child 102796 e8bab55ac425419721f047c4c9dfc799d838b5a8
push id1316
push userakeybl@mozilla.com
push dateMon, 27 Aug 2012 22:37:00 +0000
treeherdermozilla-beta@db4b09302ee2 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmyk, felipc, ctalbert
bugs733631
milestone16.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 733631 - Create a unit test infrastructure for the webapp runtime. r=myk,felipc,ctalbert
testing/mochitest/browser-harness.xul
testing/mochitest/browser-test.js
testing/mochitest/runtests.py
testing/mochitest/server.js
testing/testsuite-targets.mk
webapprt/CommandLineHandler.js
webapprt/DirectoryProvider.js
webapprt/Makefile.in
webapprt/WebappRT.jsm
webapprt/content/webapp.js
webapprt/mac/webapprt.mm
webapprt/test/Makefile.in
webapprt/test/chrome/Makefile.in
webapprt/test/chrome/browser_sample.js
webapprt/test/chrome/head.js
webapprt/test/chrome/install.html
webapprt/test/chrome/sample.html
webapprt/test/chrome/sample.webapp
webapprt/test/content/Makefile.in
webapprt/test/content/helpers.js
webapprt/test/content/sample.html
webapprt/test/content/sample.webapp
webapprt/test/content/webapprt_sample.html
--- a/testing/mochitest/browser-harness.xul
+++ b/testing/mochitest/browser-harness.xul
@@ -183,17 +183,23 @@
 
     function setStatus(aStatusString) {
       document.getElementById("status").value = aStatusString;
     }
 
     function runTests() {
       var windowMediator = Cc['@mozilla.org/appshell/window-mediator;1'].
                              getService(Ci.nsIWindowMediator);
-      var testWin = windowMediator.getMostRecentWindow("navigator:browser");
+      var winType = gConfig.testRoot == "browser" ? "navigator:browser" :
+                    gConfig.testRoot == "webapprtChrome" ? "webapprt:webapp" :
+                    null;
+      if (!winType) {
+        throw new Error("Unrecognized gConfig.testRoot: " + gConfig.testRoot);
+      }
+      var testWin = windowMediator.getMostRecentWindow(winType);
 
       setStatus("Running...");
       testWin.focus();
       var Tester = new testWin.Tester(listTests(), gDumper, testsFinished);
       Tester.start();
     }
 
     function sum(a, b) {
--- a/testing/mochitest/browser-test.js
+++ b/testing/mochitest/browser-test.js
@@ -8,17 +8,17 @@ if (Cc === undefined) {
   var Cu = Components.utils;
 }
 window.addEventListener("load", testOnLoad, false);
 
 function testOnLoad() {
   window.removeEventListener("load", testOnLoad, false);
 
   gConfig = readConfig();
-  if (gConfig.testRoot == "browser") {
+  if (gConfig.testRoot == "browser" || gConfig.testRoot == "webapprtChrome") {
     // Make sure to launch the test harness for the first opened window only
     var prefs = Cc["@mozilla.org/preferences-service;1"].
                 getService(Ci.nsIPrefBranch);
     if (prefs.prefHasUserValue("testing.browserTestHarness.running"))
       return;
 
     prefs.setBoolPref("testing.browserTestHarness.running", true);
 
@@ -105,17 +105,20 @@ Tester.prototype = {
   },
 
   waitForWindowsState: function Tester_waitForWindowsState(aCallback) {
     let timedOut = this.currentTest && this.currentTest.timedOut;
     let baseMsg = timedOut ? "Found a {elt} after previous test timed out"
                            : this.currentTest ? "Found an unexpected {elt} at the end of test run"
                                               : "Found an unexpected {elt}";
 
-    if (this.currentTest && window.gBrowser && gBrowser.tabs.length > 1) {
+    if (gConfig.testRoot == "browser" &&
+        this.currentTest &&
+        window.gBrowser &&
+        gBrowser.tabs.length > 1) {
       while (gBrowser.tabs.length > 1) {
         let lastTab = gBrowser.tabContainer.lastChild;
         let msg = baseMsg.replace("{elt}", "tab") +
                   ": " + lastTab.linkedBrowser.currentURI.spec;
         this.currentTest.addResult(new testResult(false, msg, "", false));
         gBrowser.removeTab(lastTab);
       }
     }
--- a/testing/mochitest/runtests.py
+++ b/testing/mochitest/runtests.py
@@ -134,16 +134,26 @@ class MochitestOptions(optparse.OptionPa
                     help = "start in the given directory's tests")
     defaults["testPath"] = ""
 
     self.add_option("--browser-chrome",
                     action = "store_true", dest = "browserChrome",
                     help = "run browser chrome Mochitests")
     defaults["browserChrome"] = False
 
+    self.add_option("--webapprt-content",
+                    action = "store_true", dest = "webapprtContent",
+                    help = "run WebappRT content tests")
+    defaults["webapprtContent"] = False
+
+    self.add_option("--webapprt-chrome",
+                    action = "store_true", dest = "webapprtChrome",
+                    help = "run WebappRT chrome tests")
+    defaults["webapprtChrome"] = False
+
     self.add_option("--a11y",
                     action = "store_true", dest = "a11y",
                     help = "run accessibility Mochitests");
     defaults["a11y"] = False
 
     self.add_option("--setenv",
                     action = "append", type = "string",
                     dest = "environment", metavar = "NAME=VALUE",
@@ -299,16 +309,19 @@ See <http://mochikit.com/doc/html/MochiK
       options.runOnly = True
         
     if options.excludeTests:
       if not os.path.exists(os.path.abspath(options.excludeTests)):
         self.error("unable to find --exclude-tests file '%s'" % options.excludeTests);
       options.testManifest = options.excludeTests
       options.runOnly = False
 
+    if options.webapprtContent and options.webapprtChrome:
+      self.error("Only one of --webapprt-content and --webapprt-chrome may be given.")
+
     return options
 
 
 #######################
 # HTTP SERVER SUPPORT #
 #######################
 
 class MochitestServer:
@@ -318,30 +331,31 @@ class MochitestServer:
     self._automation = automation
     self._closeWhenDone = options.closeWhenDone
     self._utilityPath = options.utilityPath
     self._xrePath = options.xrePath
     self._profileDir = options.profilePath
     self.webServer = options.webServer
     self.httpPort = options.httpPort
     self.shutdownURL = "http://%(server)s:%(port)s/server/shutdown" % { "server" : self.webServer, "port" : self.httpPort }
+    self.testPrefix = "'webapprt_'" if options.webapprtContent else "undefined"
 
   def start(self):
     "Run the Mochitest server, returning the process ID of the server."
     
     env = self._automation.environment(xrePath = self._xrePath)
     env["XPCOM_DEBUG_BREAK"] = "warn"
     if self._automation.IS_WIN32:
       env["PATH"] = env["PATH"] + ";" + self._xrePath
 
     args = ["-g", self._xrePath,
             "-v", "170",
             "-f", "./" + "httpd.js",
-            "-e", "const _PROFILE_PATH = '%(profile)s';const _SERVER_PORT = '%(port)s'; const _SERVER_ADDR ='%(server)s';" % 
-                   {"profile" : self._profileDir.replace('\\', '\\\\'), "port" : self.httpPort, "server" : self.webServer },
+            "-e", "const _PROFILE_PATH = '%(profile)s';const _SERVER_PORT = '%(port)s'; const _SERVER_ADDR = '%(server)s'; const _TEST_PREFIX = %(testPrefix)s;" %
+                   {"profile" : self._profileDir.replace('\\', '\\\\'), "port" : self.httpPort, "server" : self.webServer, "testPrefix" : self.testPrefix },
             "-f", "./" + "server.js"]
 
     xpcshell = os.path.join(self._utilityPath,
                             "xpcshell" + self._automation.BIN_SUFFIX)
     self._process = self._automation.Process([xpcshell] + args, env = env)
     pid = self._process.pid
     if pid < 0:
       print "Error starting server."
@@ -542,17 +556,17 @@ class Mochitest(object):
         thisChunk -- which chunk to run
         timeout -- per-test timeout in seconds
         repeat -- How many times to repeat the test, ie: repeat=1 will run the test twice.
     """
   
     # allow relative paths for logFile
     if options.logFile:
       options.logFile = self.getLogFilePath(options.logFile)
-    if options.browserChrome or options.chrome or options.a11y:
+    if options.browserChrome or options.chrome or options.a11y or options.webapprtChrome:
       self.makeTestConfig(options)
     else:
       if options.autorun:
         self.urlOpts.append("autorun=1")
       if options.timeout:
         self.urlOpts.append("timeout=%d" % options.timeout)
       if options.closeWhenDone:
         self.urlOpts.append("closeWhenDone=1")
@@ -636,31 +650,37 @@ class Mochitest(object):
     self.startWebServer(options)
     self.startWebSocketServer(options, debuggerInfo)
 
     testURL = self.buildTestPath(options)
     self.buildURLOptions(options, browserEnv)
     if len(self.urlOpts) > 0:
       testURL += "?" + "&".join(self.urlOpts)
 
+    if options.webapprtContent:
+      options.browserArgs.extend(('-test-mode', testURL))
+      testURL = None
+
     # Remove the leak detection file so it can't "leak" to the tests run.
     # The file is not there if leak logging was not enabled in the application build.
     if os.path.exists(self.leak_report_file):
       os.remove(self.leak_report_file)
 
     # then again to actually run mochitest
     if options.timeout:
       timeout = options.timeout + 30
     elif not options.autorun:
       timeout = None
     else:
       timeout = 330.0 # default JS harness timeout is 300 seconds
 
     # it's a debug build, we can parse leaked DOMWindows and docShells
-    if Automation.IS_DEBUG_BUILD:
+    # but skip for WebappRT chrome tests, where DOMWindow "leaks" aren't
+    # meaningful.  See https://bugzilla.mozilla.org/show_bug.cgi?id=733631#c46
+    if Automation.IS_DEBUG_BUILD and not options.webapprtChrome:
       logger = ShutdownLeakLogger(self.automation.log)
     else:
       logger = None
 
     if options.vmwareRecording:
       self.startVMwareRecording(options);
 
     self.automation.log.info("INFO | runtests.py | Running tests: start.\n")
@@ -728,17 +748,19 @@ class Mochitest(object):
 
     options.logFile = options.logFile.replace("\\", "\\\\")
     options.testPath = options.testPath.replace("\\", "\\\\")
     testRoot = 'chrome'
     if (options.browserChrome):
       testRoot = 'browser'
     elif (options.a11y):
       testRoot = 'a11y'
- 
+    elif (options.webapprtChrome):
+      testRoot = 'webapprtChrome'
+
     if "MOZ_HIDE_RESULTS_TABLE" in os.environ and os.environ["MOZ_HIDE_RESULTS_TABLE"] == "1":
       options.hideResultsTable = True
 
     #TODO: when we upgrade to python 2.6, just use json.dumps(options.__dict__)
     content = "{"
     content += '"testRoot": "%s", ' % (testRoot) 
     first = True
     for opt in options.__dict__.keys():
@@ -790,23 +812,25 @@ toolbar#nav-bar {
         manifestFile.write("content mochitests %s contentaccessible=yes\n" % chrometestDir)
 
     # Call installChromeJar().
     jarDir = "mochijar"
     if not os.path.isdir(os.path.join(self.SCRIPT_DIRECTORY, jarDir)):
       self.automation.log.warning("TEST-UNEXPECTED-FAIL | invalid setup: missing mochikit extension")
       return None
 
-    # Support Firefox (browser), B2G (shell) and SeaMonkey (navigator).
+    # Support Firefox (browser), B2G (shell), SeaMonkey (navigator), and Webapp
+    # Runtime (webapp).
     chrome = ""
-    if options.browserChrome or options.chrome or options.a11y:
+    if options.browserChrome or options.chrome or options.a11y or options.webapprtChrome:
       chrome += """
 overlay chrome://browser/content/browser.xul chrome://mochikit/content/browser-test-overlay.xul
 overlay chrome://browser/content/shell.xul chrome://mochikit/content/browser-test-overlay.xul
 overlay chrome://navigator/content/navigator.xul chrome://mochikit/content/browser-test-overlay.xul
+overlay chrome://webapprt/content/webapp.xul chrome://mochikit/content/browser-test-overlay.xul
 """
 
     self.installChromeJar(jarDir, chrome, options)
     return manifest
 
   def installChromeJar(self, jarDirName, chrome, options):
     """
       copy mochijar directory to profile as an extension so we have chrome://mochikit for all harness code
--- a/testing/mochitest/server.js
+++ b/testing/mochitest/server.js
@@ -426,18 +426,20 @@ function list(requestPath, directory, re
  * a supporting file.
  */
 function isTest(filename, pattern)
 {
   if (pattern)
     return pattern.test(filename);
 
   // File name is a URL style path to a test file, make sure that we check for
-  // tests that start with test_.
-  testPattern = /^test_/;
+  // tests that start with the appropriate prefix.
+  var testPrefix = typeof(_TEST_PREFIX) == "string" ? _TEST_PREFIX : "test_";
+  var testPattern = new RegExp("^" + testPrefix);
+
   pathPieces = filename.split('/');
     
   return testPattern.test(pathPieces[pathPieces.length - 1]) &&
          filename.indexOf(".js") == -1 &&
          filename.indexOf(".css") == -1 &&
          !/\^headers\^$/.test(filename);
 }
 
--- a/testing/testsuite-targets.mk
+++ b/testing/testsuite-targets.mk
@@ -124,16 +124,26 @@ endif
 ifeq (powerpc,$(TARGET_CPU))
 	$(RUN_MOCHITEST) --setpref=dom.ipc.plugins.enabled.ppc.test.plugin=false --test-path=dom/plugins/test
 endif
 else
 	$(RUN_MOCHITEST) --setpref=dom.ipc.plugins.enabled=false --test-path=dom/plugins/test
 endif
 	$(CHECK_TEST_ERROR)
 
+ifeq ($(OS_ARCH),Darwin)
+webapprt_stub_path = $(TARGET_DIST)/$(MOZ_MACBUNDLE_NAME)/Contents/MacOS/webapprt-stub$(BIN_SUFFIX)
+webapprt-test-content:
+	$(RUN_MOCHITEST) --webapprt-content --appname $(webapprt_stub_path)
+	$(CHECK_TEST_ERROR)
+webapprt-test-chrome:
+	$(RUN_MOCHITEST) --webapprt-chrome --appname $(webapprt_stub_path) --browser-arg -test-mode
+	$(CHECK_TEST_ERROR)
+endif
+
 # Usage: |make [EXTRA_TEST_ARGS=...] *test|.
 RUN_REFTEST = rm -f ./$@.log && $(PYTHON) _tests/reftest/runreftest.py \
   $(SYMBOLS_PATH) $(EXTRA_TEST_ARGS) $(1) | tee ./$@.log
 
 REMOTE_REFTEST = rm -f ./$@.log && $(PYTHON) _tests/reftest/remotereftest.py \
   --dm_trans=$(DM_TRANS) --ignore-window-size \
   --app=$(TEST_PACKAGE_NAME) --deviceIP=${TEST_DEVICE} --xre-path=${MOZ_HOST_BIN} \
   $(SYMBOLS_PATH) $(EXTRA_TEST_ARGS) $(1) | tee ./$@.log
--- a/webapprt/CommandLineHandler.js
+++ b/webapprt/CommandLineHandler.js
@@ -12,62 +12,116 @@ Cu.import("resource://gre/modules/Servic
 function CommandLineHandler() {}
 
 CommandLineHandler.prototype = {
   classID: Components.ID("{6d69c782-40a3-469b-8bfd-3ee366105a4a}"),
 
   QueryInterface: XPCOMUtils.generateQI([Ci.nsICommandLineHandler]),
 
   handle: function handle(cmdLine) {
+    let args = Cc["@mozilla.org/hash-property-bag;1"].
+               createInstance(Ci.nsIWritablePropertyBag);
+    let inTestMode = this._handleTestMode(cmdLine, args);
+    Services.obs.notifyObservers(args, "webapprt-command-line", null);
+
+    // Initialize DOMApplicationRegistry by importing Webapps.jsm, but only
+    // after broadcasting webapprt-command-line.  Webapps.jsm calls
+    // DOMApplicationRegistry.init() when it's first imported.  init() accesses
+    // the WebappRegD directory, which in test mode is special-cased by
+    // DirectoryProvider.js after it observes webapprt-command-line.
+    Cu.import("resource://gre/modules/Webapps.jsm");
+
+    if (!inTestMode) {
+      startUp();
+    } else {
+      // startUp() accesses WebappRT.config, which in test mode is not valid
+      // until WebappRT.jsm observes an app installation.
+      Services.obs.addObserver(function onInstall(subj, topic, data) {
+        Services.obs.removeObserver(onInstall, "webapprt-test-did-install");
+        startUp();
+      }, "webapprt-test-did-install", false);
+    }
+
     // Open the window with arguments to identify it as the main window
     Services.ww.openWindow(null,
                            "chrome://webapprt/content/webapp.xul",
                            "_blank",
                            "chrome,dialog=no,resizable,scrollbars,centerscreen",
-                           []);
+                           args);
+  },
 
-    // Initialize window-independent handling of webapps- notifications
-    Cu.import("resource://webapprt/modules/WebappsHandler.jsm");
-    WebappsHandler.init();
+  _handleTestMode: function _handleTestMode(cmdLine, args) {
+    // -test-mode [url]
+    let idx = cmdLine.findFlag("test-mode", true);
+    if (idx < 0)
+      return false;
+    let url = null;
+    let urlIdx = idx + 1;
+    if (urlIdx < cmdLine.length) {
+      let potentialURL = cmdLine.getArgument(urlIdx);
+      if (potentialURL && potentialURL[0] != "-") {
+        url = potentialURL;
+        try {
+          Services.io.newURI(url, null, null);
+        } catch (err) {
+          throw Components.Exception(
+            "-test-mode argument is not a valid URL: " + url,
+            Components.results.NS_ERROR_INVALID_ARG);
+        }
+        cmdLine.removeArguments(urlIdx, urlIdx);
+      }
+    }
+    cmdLine.removeArguments(idx, idx);
+    args.setProperty("test-mode", url);
+    return true;
   },
 
   helpInfo : "",
 };
 
 let components = [CommandLineHandler];
 let NSGetFactory = XPCOMUtils.generateNSGetFactory(components);
 
 /* There's some work we need to do on startup, before we load the webapp,
  * and this seems as good a place as any to do it, although it's possible
  * that in the future we will find a lazier place to do it.
  *
  * NOTE: it's very important that the stuff we do here doesn't prevent
  * the command-line handler from being registered/accessible, since otherwise
  * the app won't start, which is catastrophic failure.  That's why it's all
  * wrapped in a try/catch block. */
-
-try {
-  // Initialize DOMApplicationRegistry so it can receive/respond to messages.
-  Cu.import("resource://gre/modules/Webapps.jsm");
+function startUp(inTestMode) {
+  try {
+    if (!inTestMode) {
+      // Initialize window-independent handling of webapps- notifications.  Skip
+      // this in test mode, since it interferes with test app installations.
+      // We'll have to revisit this when we actually want to test installations
+      // and other functionality provided by WebappsHandler.
+      Cu.import("resource://webapprt/modules/WebappsHandler.jsm");
+      WebappsHandler.init();
+    }
 
-  // On firstrun, set permissions to their default values.
-  if (!Services.prefs.getBoolPref("webapprt.firstrun")) {
-    Cu.import("resource://webapprt/modules/WebappRT.jsm");
-    let uri = Services.io.newURI(WebappRT.config.app.origin, null, null);
+    // On firstrun, set permissions to their default values.
+    if (!Services.prefs.getBoolPref("webapprt.firstrun")) {
+      Cu.import("resource://webapprt/modules/WebappRT.jsm");
+      let uri = Services.io.newURI(WebappRT.config.app.origin, null, null);
 
-    // Set AppCache-related permissions.
-    Services.perms.add(uri, "pin-app", Ci.nsIPermissionManager.ALLOW_ACTION);
-    Services.perms.add(uri, "offline-app",
-                       Ci.nsIPermissionManager.ALLOW_ACTION);
+      // Set AppCache-related permissions.
+      Services.perms.add(uri, "pin-app",
+                         Ci.nsIPermissionManager.ALLOW_ACTION);
+      Services.perms.add(uri, "offline-app",
+                         Ci.nsIPermissionManager.ALLOW_ACTION);
 
-    Services.perms.add(uri, "indexedDB", Ci.nsIPermissionManager.ALLOW_ACTION);
-    Services.perms.add(uri, "indexedDB-unlimited",
-                       Ci.nsIPermissionManager.ALLOW_ACTION);
+      Services.perms.add(uri, "indexedDB",
+                         Ci.nsIPermissionManager.ALLOW_ACTION);
+      Services.perms.add(uri, "indexedDB-unlimited",
+                         Ci.nsIPermissionManager.ALLOW_ACTION);
 
-    // Now that we've set the appropriate permissions, twiddle the firstrun flag
-    // so we don't try to do so again.
-    Services.prefs.setBoolPref("webapprt.firstrun", true);
+      // Now that we've set the appropriate permissions, twiddle the firstrun
+      // flag so we don't try to do so again.
+      Services.prefs.setBoolPref("webapprt.firstrun", true);
+    }
+  } catch(ex) {
+#ifdef MOZ_DEBUG
+    dump(ex + "\n");
+#endif
   }
-} catch(ex) {
-#ifdef MOZ_DEBUG
-  dump(ex + "\n");
-#endif
 }
--- a/webapprt/DirectoryProvider.js
+++ b/webapprt/DirectoryProvider.js
@@ -7,27 +7,42 @@ const Ci = Components.interfaces;
 const Cu = Components.utils;
 
 const WEBAPP_REGISTRY_DIR = "WebappRegD";
 const NS_APP_CHROME_DIR_LIST = "AChromDL";
 
 Cu.import("resource://gre/modules/FileUtils.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://webapprt/modules/WebappRT.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+
+let gInTestMode = false;
+Services.obs.addObserver(function observe(subj, topic, data) {
+  Services.obs.removeObserver(observe, "webapprt-command-line");
+  let args = subj.QueryInterface(Ci.nsIPropertyBag2);
+  gInTestMode = args.hasKey("test-mode");
+}, "webapprt-command-line", false);
 
 function DirectoryProvider() {}
 
 DirectoryProvider.prototype = {
   classID: Components.ID("{e1799fda-4b2f-4457-b671-e0641d95698d}"),
 
   QueryInterface: XPCOMUtils.generateQI([Ci.nsIDirectoryServiceProvider,
                                          Ci.nsIDirectoryServiceProvider2]),
 
   getFile: function(prop, persistent) {
     if (prop == WEBAPP_REGISTRY_DIR) {
+      if (gInTestMode) {
+        // In test mode, apps are registered in the runtime's profile.  Note
+        // that in test mode WebappRT.config may not be valid at this point.
+        // It's only valid after WebappRT.jsm observes an app installation, and
+        // we can get here before any app is installed.
+        return FileUtils.getDir("ProfD", []);
+      }
       let dir = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsILocalFile);
       dir.initWithPath(WebappRT.config.registryDir);
       return dir;
     }
 
     // We return null to show failure instead of throwing an error,
     // which works with the way the interface is called (per bug 529077).
     return null;
--- a/webapprt/Makefile.in
+++ b/webapprt/Makefile.in
@@ -41,16 +41,20 @@ EXTRA_PP_COMPONENTS = \
 EXTRA_JS_MODULES = \
   WebappRT.jsm \
   WebappsHandler.jsm \
   $(NULL)
 
 PREF_JS_EXPORTS = $(srcdir)/prefs.js \
                   $(NULL)
 
+TEST_DIRS += \
+  test \
+  $(NULL)
+
 include $(topsrcdir)/config/rules.mk
 
 ifdef MOZ_DEBUG
 DEFINES += -DMOZ_DEBUG=1
 endif
 
 ifdef MOZILLA_OFFICIAL
 DEFINES += -DMOZILLA_OFFICIAL
--- a/webapprt/WebappRT.jsm
+++ b/webapprt/WebappRT.jsm
@@ -4,22 +4,46 @@
 
 const EXPORTED_SYMBOLS = ["WebappRT"];
 
 const Cc = Components.classes;
 const Ci = Components.interfaces;
 const Cu = Components.utils;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
 
 XPCOMUtils.defineLazyGetter(this, "FileUtils", function() {
   Cu.import("resource://gre/modules/FileUtils.jsm");
   return FileUtils;
 });
 
+XPCOMUtils.defineLazyGetter(this, "DOMApplicationRegistry", function() {
+  Cu.import("resource://gre/modules/Webapps.jsm");
+  return DOMApplicationRegistry;
+});
+
+// In test mode, observe webapps-ask-install so tests can install apps.
+Services.obs.addObserver(function observeCmdLine(subj, topic, data) {
+  Services.obs.removeObserver(observeCmdLine, "webapprt-command-line");
+  let args = subj.QueryInterface(Ci.nsIPropertyBag2);
+  if (!args.hasKey("test-mode"))
+    return;
+  Services.obs.addObserver(function observeInstall(subj, topic, data) {
+    // observeInstall is present for the lifetime of the runtime.
+    let config = JSON.parse(data);
+    config.registryDir = Services.dirsvc.get("ProfD", Ci.nsIFile).path;
+    delete WebappRT.config;
+    WebappRT.config = deepFreeze(config);
+    DOMApplicationRegistry.confirmInstall(config);
+    Services.obs.notifyObservers(null, "webapprt-test-did-install",
+                                 JSON.stringify(config));
+  }, "webapps-ask-install", false);
+}, "webapprt-command-line", false);
+
 let WebappRT = {
   get config() {
     let webappFile = FileUtils.getFile("AppRegD", ["webapp.json"]);
     let inputStream = Cc["@mozilla.org/network/file-input-stream;1"].
                       createInstance(Ci.nsIFileInputStream);
     inputStream.init(webappFile, -1, 0, 0);
     let json = Cc["@mozilla.org/dom/json;1"].createInstance(Ci.nsIJSON);
     let config = json.decodeFromStream(inputStream, webappFile.fileSize);
--- a/webapprt/content/webapp.js
+++ b/webapprt/content/webapp.js
@@ -7,37 +7,62 @@ const Ci = Components.interfaces;
 const Cu = Components.utils;
 
 Cu.import("resource://webapprt/modules/WebappRT.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 
 function onLoad() {
   window.removeEventListener("load", onLoad, false);
 
+  let cmdLineArgs = window.arguments && window.arguments[0] ?
+                    window.arguments[0].QueryInterface(Ci.nsIPropertyBag2) :
+                    null;
+
+  // In test mode, listen for test app installations and load the -test-mode URL
+  // if present.
+  if (cmdLineArgs && cmdLineArgs.hasKey("test-mode")) {
+    Services.obs.addObserver(function observe(subj, topic, data) {
+      // The observer is present for the lifetime of the runtime.
+      initWindow(false);
+    }, "webapprt-test-did-install", false);
+    let testURL = cmdLineArgs.get("test-mode");
+    if (testURL) {
+      document.getElementById("content").loadURI(testURL);
+    }
+    return;
+  }
+
+  initWindow(!!cmdLineArgs);
+}
+
+window.addEventListener("load", onLoad, false);
+
+function initWindow(isMainWindow) {
   // Set the title of the window to the name of the webapp
   let manifest = WebappRT.config.app.manifest;
   document.documentElement.setAttribute("title", manifest.name);
 
+  updateMenuItems();
+
   // Listen for clicks to redirect <a target="_blank"> to the browser.
   // This doesn't capture clicks so content can capture them itself and do
   // something different if it doesn't want the default behavior.
   document.getElementById("content").addEventListener("click", onContentClick,
                                                       false, true);
 
   // Only load the webapp on the initially launched main window
-  if ("arguments" in window) {
+  if (isMainWindow) {
     // Load the webapp's launch URL
     let installRecord = WebappRT.config.app;
     let url = Services.io.newURI(installRecord.origin, null, null);
     if (manifest.launch_path)
       url = Services.io.newURI(manifest.launch_path, null, url);
     document.getElementById("content").setAttribute("src", url.spec);
   }
 }
-window.addEventListener("load", onLoad, false);
 
 /**
  * Direct a click on <a target="_blank"> to the user's default browser.
  *
  * In the long run, it might be cleaner to move this to an extension of
  * nsIWebBrowserChrome3::onBeforeLinkTraversal.
  *
  * @param {DOMEvent} event the DOM event
@@ -61,33 +86,32 @@ function onContentClick(event) {
     launchWithURI(uri);
 
   // Prevent the runtime from loading the URL.  We do this after directing it
   // to the browser to give the runtime a shot at handling the URL if we fail
   // to direct it to the browser for some reason.
   event.preventDefault();
 }
 
-#ifdef XP_MACOSX
 // On Mac, we dynamically create the label for the Quit menuitem, using
 // a string property to inject the name of the webapp into it.
-window.addEventListener("load", function onLoadUpdateMenuItems() {
-  window.removeEventListener("load", onLoadUpdateMenuItems, false);
+function updateMenuItems() {
+#ifdef XP_MACOSX
   let installRecord = WebappRT.config.app;
   let manifest = WebappRT.config.app.manifest;
   let bundle =
     Services.strings.createBundle("chrome://webapprt/locale/webapp.properties");
   let quitLabel = bundle.formatStringFromName("quitApplicationCmdMac.label",
                                               [manifest.name], 1);
   let hideLabel = bundle.formatStringFromName("hideApplicationCmdMac.label",
                                               [manifest.name], 1);
   document.getElementById("menu_FileQuitItem").setAttribute("label", quitLabel);
   document.getElementById("menu_mac_hide_app").setAttribute("label", hideLabel);
-}, false);
 #endif
+}
 
 function updateEditUIVisibility() {
 #ifndef XP_MACOSX
   let editMenuPopupState = document.getElementById("menu_EditPopup").state;
 
   // The UI is visible if the Edit menu is opening or open, if the context menu
   // is open, or if the toolbar has been customized to include the Cut, Copy,
   // or Paste toolbar buttons.
--- a/webapprt/mac/webapprt.mm
+++ b/webapprt/mac/webapprt.mm
@@ -85,16 +85,19 @@ AttemptGRELoad(char *greDir)
   return rv;
 }
 
 int
 main(int argc, char **argv)
 {
   NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
 
+  NSDictionary *args = [[NSUserDefaults standardUserDefaults]
+                         volatileDomainForName:NSArgumentDomain];
+
   NSString *firefoxPath = nil;
   NSString *alternateBinaryID = nil;
 
   //this is our version, to be compared with the version of the binary we are asked to use
   NSString* myVersion = [NSString stringWithFormat:@"%s", NS_STRINGIFY(GRE_BUILDID)];
 
   NSLog(@"MY WEBAPPRT BUILDID: %@", myVersion);
 
@@ -194,21 +197,16 @@ main(int argc, char **argv)
       if (!NS_SUCCEEDED(AttemptGRELoad(greDir))) {
           @throw MakeException(@"Error", @"Unable to load XUL files for application startup");
       }
 
       // NOTE: The GRE has successfully loaded, so we can use XPCOM now
 
       NS_LogInit();
       { // Scope for any XPCOM stuff we create
-        nsINIParser parser;
-        if (NS_FAILED(parser.Init(appEnv))) {
-          NSLog(@"%s was not found\n", appEnv);
-          @throw MakeException(@"Error", @"Unable to parse environment files for application startup");
-        }
 
         // Get the path to the runtime directory.
         char rtDir[MAXPATHLEN];
         snprintf(rtDir, MAXPATHLEN, "%s%s%s", [firefoxPath UTF8String], APP_CONTENTS_PATH, WEBAPPRT_PATH);
 
         // Get the path to the runtime's INI file.  This is in the runtime
         // directory.
         snprintf(rtINIPath, MAXPATHLEN, "%s%s%s%s", [firefoxPath UTF8String], APP_CONTENTS_PATH, WEBAPPRT_PATH, WEBRTINI_NAME);
@@ -229,23 +227,34 @@ main(int argc, char **argv)
         }
 
         nsXREAppData *webShellAppData;
         if (NS_FAILED(XRE_CreateAppData(rtINI, &webShellAppData))) {
           NSLog(@"Couldn't read WebappRT application.ini: %s", rtINIPath);
           @throw MakeException(@"Error", @"Unable to parse base INI file.");
         }
 
-        char profile[MAXPATHLEN];
-        if (NS_FAILED(parser.GetString("Webapp", "Profile", profile, MAXPATHLEN))) {
-          NSLog(@"Unable to retrieve profile from web app INI file");
-          @throw MakeException(@"Error", @"Unable to retrieve installation profile.");
+        NSString *profile = [args objectForKey:@"profile"];
+        if (profile) {
+          NSLog(@"Profile specified with -profile: %@", profile);
         }
-        NSLog(@"setting app profile: %s", profile);
-        SetAllocatedString(webShellAppData->profile, profile);
+        else {
+          nsINIParser parser;
+          if (NS_FAILED(parser.Init(appEnv))) {
+            NSLog(@"%s was not found\n", appEnv);
+            @throw MakeException(@"Error", @"Unable to parse environment files for application startup");
+          }
+          char profile[MAXPATHLEN];
+          if (NS_FAILED(parser.GetString("Webapp", "Profile", profile, MAXPATHLEN))) {
+            NSLog(@"Unable to retrieve profile from web app INI file");
+            @throw MakeException(@"Error", @"Unable to retrieve installation profile.");
+          }
+          NSLog(@"setting app profile: %s", profile);
+          SetAllocatedString(webShellAppData->profile, profile);
+        }
 
         nsCOMPtr<nsIFile> directory;
         if (NS_FAILED(XRE_GetFileFromPath(rtDir, getter_AddRefs(directory)))) {
           NSLog(@"Unable to open app dir");
           @throw MakeException(@"Error", @"Unable to open application directory.");
         }
 
         nsCOMPtr<nsIFile> xreDir;
@@ -304,16 +313,24 @@ DisplayErrorAlert(NSString* title, NSStr
 /* Find the currently installed Firefox, if any, and return
  * an absolute path to it. may return nil */
 NSString
 *PathToWebRT(NSString* alternateBinaryID)
 {
   //default is firefox
   NSString *binaryPath = nil;
 
+  // We're run from the Firefox bundle during WebappRT chrome and content tests.
+  NSString *myBundlePath = [[NSBundle mainBundle] bundlePath];
+  NSString *fxPath = [NSString stringWithFormat:@"%@%sfirefox-bin",
+                                 myBundlePath, APP_CONTENTS_PATH];
+  if ([[NSFileManager defaultManager] fileExistsAtPath:fxPath]) {
+    return myBundlePath;
+  }
+
   //we look for these flavors of Firefox, in this order
   NSArray* launchBinarySearchList = [NSArray arrayWithObjects: @"org.mozilla.nightly",
                                                                 @"org.mozilla.aurora",
                                                                 @"org.mozilla.firefox", nil];
 
   //if they provided a manual override, use that.  If they made an error, it will fail to launch
   if (alternateBinaryID != nil && ([alternateBinaryID length] > 0)) {
     binaryPath = [[NSWorkspace sharedWorkspace] absolutePathForAppBundleWithIdentifier:alternateBinaryID];
new file mode 100644
--- /dev/null
+++ b/webapprt/test/Makefile.in
@@ -0,0 +1,17 @@
+# 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/.
+
+DEPTH     = ../..
+topsrcdir = @top_srcdir@
+srcdir    = @srcdir@
+VPATH     = @srcdir@
+
+include $(DEPTH)/config/autoconf.mk
+
+DIRS += \
+  chrome \
+  content \
+  $(NULL)
+
+include $(topsrcdir)/config/rules.mk
new file mode 100644
--- /dev/null
+++ b/webapprt/test/chrome/Makefile.in
@@ -0,0 +1,23 @@
+# 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/.
+
+DEPTH          = ../../..
+topsrcdir      = @top_srcdir@
+srcdir         = @srcdir@
+VPATH          = @srcdir@
+relativesrcdir = webapprt/test/chrome
+
+include $(DEPTH)/config/autoconf.mk
+include $(topsrcdir)/config/rules.mk
+
+_BROWSER_TEST_FILES = \
+  head.js \
+  install.html \
+  browser_sample.js \
+    sample.webapp \
+    sample.html \
+  $(NULL)
+
+libs:: $(_BROWSER_TEST_FILES)
+	$(INSTALL) $(foreach f,$^,"$f") $(DEPTH)/_tests/testing/mochitest/webapprtChrome/$(relativesrcdir)
new file mode 100644
--- /dev/null
+++ b/webapprt/test/chrome/browser_sample.js
@@ -0,0 +1,19 @@
+// This is a sample WebappRT chrome test.  It's just a browser-chrome mochitest.
+
+function test() {
+  waitForExplicitFinish();
+  ok(true, "true is true!");
+  installWebapp("sample.webapp", undefined, function onInstall(appConfig) {
+    is(document.documentElement.getAttribute("title"),
+       appConfig.app.manifest.name,
+       "Window title should be webapp name");
+    let content = document.getElementById("content");
+    let msg = content.contentDocument.getElementById("msg");
+    var observer = new MutationObserver(function (mutations) {
+      ok(/^Webapp getSelf OK:/.test(msg.textContent),
+         "The webapp should have successfully installed and updated its msg");
+      finish();
+    });
+    observer.observe(msg, { childList: true });
+  });
+}
new file mode 100644
--- /dev/null
+++ b/webapprt/test/chrome/head.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/. */
+
+const INSTALL_URL =
+  "http://mochi.test:8888/webapprtChrome/webapprt/test/chrome/install.html";
+
+Cu.import("resource://gre/modules/Services.jsm");
+
+/**
+ * Installs the given webapp and navigates to it.
+ *
+ * @param manifestPath
+ *        The path of the webapp's manifest relative to
+ *        http://mochi.test:8888/webapprtChrome/webapprt/test/chrome/.
+ * @param parameters
+ *        The value to pass as the "parameters" argument to
+ *        mozIDOMApplicationRegistry.install, e.g., { receipts: ... }.  Use
+ *        undefined to pass nothing.
+ * @param callback
+ *        Called when the newly installed webapp is navigated to.  It's passed
+ *        the webapp's config object.
+ */
+function installWebapp(manifestPath, parameters, onInstall) {
+  // Three steps: (1) Load install.html, (2) listen for webapprt-test-did-
+  // install to get the app config object, and then (3) listen for load of the
+  // webapp page to call the callback.  webapprt-test-did-install will be
+  // broadcasted before install.html navigates to the webapp page.  (This is due
+  // to some implementation details: WebappRT.jsm confirms the installation by
+  // calling DOMApplicationRegistry.confirmInstall, and then it immediately
+  // broadcasts webapprt-test-did-install.  confirmInstall asynchronously
+  // notifies the mozApps consumer via onsuccess, which is when install.html
+  // navigates to the webapp page.)
+
+  let content = document.getElementById("content");
+
+  Services.obs.addObserver(function observe(subj, topic, data) {
+    // step 2
+    Services.obs.removeObserver(observe, "webapprt-test-did-install");
+    let appConfig = JSON.parse(data);
+
+    content.addEventListener("load", function onLoad(event) {
+      // step 3
+      content.removeEventListener("load", onLoad, true);
+      let webappURL = appConfig.app.origin + appConfig.app.manifest.launch_path;
+      is(event.target.URL, webappURL,
+         "No other page should have loaded between installation and " +
+         "the webapp's page load: " + event.target.URL);
+      onInstall(appConfig);
+    }, true);
+  }, "webapprt-test-did-install", false);
+
+  // step 1
+  let args = [["manifestPath", manifestPath]];
+  if (parameters !== undefined) {
+    args.push(["parameters", parameters]);
+  }
+  let queryStr = args.map(function ([key, val])
+                          key + "=" + encodeURIComponent(JSON.stringify(val))).
+                 join("&");
+  let installURL = INSTALL_URL + "?" + queryStr;
+  content.loadURI(installURL);
+}
new file mode 100644
--- /dev/null
+++ b/webapprt/test/chrome/install.html
@@ -0,0 +1,56 @@
+<!DOCTYPE HTML>
+
+<!--
+  Chrome tests load this file to install their webapps.  Pass manifestPath=path
+  in the query string to install the app with the manifest at
+  http://mochi.test:8888/webapprtChrome/webapprt/test/chrome/<path>.
+-->
+
+<html>
+  <head>
+    <meta charset="utf-8">
+    <script>
+
+function parseQueryStr() {
+  return window.location.search.substr(1).split("&").
+         map(function (pairStr) pairStr.split("=")).
+         reduce(function (memo, [key, val]) {
+           memo[key] = JSON.parse(decodeURIComponent(val));
+           return memo;
+         }, {});
+}
+
+function msg(str) {
+  document.getElementById("msg").textContent = str;
+}
+
+function onLoad() {
+  var args = parseQueryStr();
+  if (!args.manifestPath) {
+    msg("No manifest path given, so standing by.");
+    return;
+  }
+  var manifestURL =
+    "http://mochi.test:8888/webapprtChrome/webapprt/test/chrome/" +
+     args.manifestPath;
+  var installArgs = [manifestURL, args.parameters];
+  msg("Installing webapp with arguments " + installArgs.toSource() + "...");
+  var install = navigator.mozApps.install.apply(navigator.mozApps, installArgs);
+  install.onsuccess = function (event) {
+    msg("Webapp installed, now navigating to it.");
+    var testAppURL = install.result.origin +
+                     install.result.manifest.launch_path;
+    window.location = testAppURL;
+  };
+  install.onerror = function () {
+    msg("Webapp installation failed with " + install.error.name +
+        " for manifest " + manifestURL);
+  };
+}
+
+    </script>
+  </head>
+  <body onload="onLoad();" onunload="">
+    <p id="msg">Installation page waiting for page load...</p>
+  </body>
+</html>
new file mode 100644
--- /dev/null
+++ b/webapprt/test/chrome/sample.html
@@ -0,0 +1,30 @@
+<!DOCTYPE HTML>
+
+<!--
+  This is the webapp.  It doesn't need to do anything in particular, just
+  whatever you want to test from your browser_ test file.
+-->
+
+<html>
+  <head>
+    <meta charset="utf-8">
+    <script>
+
+function onLoad() {
+  var msg = document.getElementById("msg");
+  var self = navigator.mozApps.getSelf();
+  self.onsuccess = function () {
+    msg.textContent = "Webapp getSelf OK: " + self.result;
+  };
+  self.onerror = function () {
+    msg.textContent = "Webapp getSelf failed: " + self.error.name;
+  };
+}
+
+    </script>
+  </head>
+  <body onload="onLoad();" onunload="">
+    <p>This is the test webapp.</p>
+    <p id="msg">Webapp waiting for page load...</p>
+  </body>
+</html>
new file mode 100644
--- /dev/null
+++ b/webapprt/test/chrome/sample.webapp
@@ -0,0 +1,1 @@
+{"name": "Sample Test Webapp", "description": "A webapp that demonstrates how to make a WebappRT test.", "launch_path": "/webapprtChrome/webapprt/test/chrome/sample.html" }
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/webapprt/test/content/Makefile.in
@@ -0,0 +1,22 @@
+# 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/.
+
+DEPTH          = ../../..
+topsrcdir      = @top_srcdir@
+srcdir         = @srcdir@
+VPATH          = @srcdir@
+relativesrcdir = webapprt/test/content
+
+include $(DEPTH)/config/autoconf.mk
+include $(topsrcdir)/config/rules.mk
+
+_TEST_FILES = \
+  helpers.js \
+  webapprt_sample.html \
+    sample.webapp \
+    sample.html \
+  $(NULL)
+
+libs:: $(_TEST_FILES)
+	$(INSTALL) $(foreach f,$^,"$f") $(DEPTH)/_tests/testing/mochitest/tests/$(relativesrcdir)
new file mode 100644
--- /dev/null
+++ b/webapprt/test/content/helpers.js
@@ -0,0 +1,57 @@
+/**
+ * Shows a message on the page.
+ *
+ * @param str
+ *        The message to show.
+ */
+function msg(str) {
+  document.getElementById("msg").textContent = str;
+}
+
+/**
+ * Installs the specified webapp and navigates to it in the iframe.
+ *
+ * @param manifestPath
+ *        The path of the manifest relative to
+ *        http://mochi.test:8888/tests/webapprt/test/content/.
+ * @param parameters
+ *        The value to pass as the "parameters" argument to
+ *        mozIDOMApplicationRegistry.install, e.g., { receipts: ... }.  Use
+ *        undefined to pass nothing.
+ */
+function installWebapp(manifestPath, parameters) {
+  var manifestURL = "http://mochi.test:8888/tests/webapprt/test/content/" +
+                    manifestPath;
+  var installArgs = [manifestURL, parameters];
+  msg("Installing webapp with arguments " + installArgs.toSource() + "...");
+  var install = navigator.mozApps.install.apply(navigator.mozApps, installArgs);
+  install.onsuccess = function (event) {
+    msg("Webapp installed.");
+    var testAppURL = install.result.origin +
+                     install.result.manifest.launch_path +
+                     window.location.search;
+    document.getElementById("webapp-iframe").src = testAppURL;
+  };
+  install.onerror = function () {
+    msg("Webapp installation failed with " + install.error.name +
+        " for manifest " + manifestURL);
+  };
+}
+
+/**
+ * If webapprt_foo.html is loaded in the window, this function installs the
+ * webapp whose manifest is named foo.webapp.
+ *
+ * @param parameters
+ *        The value to pass as the "parameters" argument to
+ *        mozIDOMApplicationRegistry.install, e.g., { receipts: ... }.  Use
+ *        undefined to pass nothing.
+ */
+function installOwnWebapp(parameters) {
+  var match = /webapprt_(.+)\.html$/.exec(window.location.pathname);
+  if (!match) {
+    throw new Error("Test URL is unconventional, so could not derive a " +
+                    "manifest URL from it: " + window.location);
+  }
+  installWebapp(match[1] + ".webapp", parameters);
+}
new file mode 100644
--- /dev/null
+++ b/webapprt/test/content/sample.html
@@ -0,0 +1,42 @@
+<!DOCTYPE HTML>
+
+<!--
+  This file is the actual test.  It's a webapp installed by webapprt_sample.html
+  and loaded into its iframe.  It's just a plain mochitest.
+-->
+
+<html>
+  <head>
+    <meta charset="utf-8">
+    <script src="/tests/SimpleTest/SimpleTest.js"></script>
+    <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+  </head>
+  <body>
+    <p id="display">
+      This is the test webapp.
+    </p>
+    <div id="content" style="display: none"></div>
+    <pre id="test">
+      <script>
+
+SimpleTest.waitForExplicitFinish();
+
+ok(true, "true is true!");
+
+var self = navigator.mozApps.getSelf();
+self.onsuccess = function () {
+  ok(true, "onsuccess should be called");
+  ok(self.result, "result should be nonnull");
+  ok(self.result.manifest, "manifest should be nonnull");
+  is(self.result.manifest.name, "Sample Test Webapp", "manifest.name");
+  SimpleTest.finish();
+};
+self.onerror = function () {
+  ok(false, "onerror should not be called");
+  SimpleTest.finish();
+};
+
+      </script>
+    </pre>
+  </body>
+</html>
new file mode 100644
--- /dev/null
+++ b/webapprt/test/content/sample.webapp
@@ -0,0 +1,1 @@
+{"name": "Sample Test Webapp", "description": "A webapp that demonstrates how to make a WebappRT test.", "launch_path": "/tests/webapprt/test/content/sample.html" }
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/webapprt/test/content/webapprt_sample.html
@@ -0,0 +1,25 @@
+<!DOCTYPE HTML>
+
+<!--
+  Since its name is prefixed with webapprt_, this file is picked up by the
+  mochitest harness.  Once loaded, it installs the webapp, and when the
+  installation completes, it loads the webapp into an iframe.  The webapp is
+  the actual test; see sample.html.
+-->
+
+<html>
+  <head>
+    <meta charset="utf-8">
+    <script src="helpers.js"></script>
+    <script>
+
+// If your installation page needs to do anything other than call
+// installOwnWebapp, you can do it here.
+
+    </script>
+  </head>
+  <body onload="installOwnWebapp();">
+    <p id="msg">Installation page waiting for page load...</p>
+    <iframe id="webapp-iframe" width="100%" height="93%"></iframe>
+  </body>
+</html>