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 idunknown
push userunknown
push dateunknown
reviewersmyk, felipc, ctalbert
bugs733631
milestone16.0a1
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>