Backed out changeset 0cafbf944d89 (bug 1318879) for robocop failures a=backout
authorWes Kocher <wkocher@mozilla.com>
Mon, 21 Nov 2016 13:01:21 -0800
changeset 370991 446e2b8e175f3c6c83601e1e403f600ca5b2a4da
parent 370990 cfff89d0d8c9bd042265d55585b749cbd5869b6a
child 370992 9766420605fa47734038032d1b8e78e6962a4f92
push id1419
push userjlund@mozilla.com
push dateMon, 10 Apr 2017 20:44:07 +0000
treeherdermozilla-release@5e6801b73ef6 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersbackout
bugs1318879
milestone53.0a1
backs out0cafbf944d89de58b012b7e4ccd96a0214373416
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
Backed out changeset 0cafbf944d89 (bug 1318879) for robocop failures a=backout
build/automation.py.in
testing/mochitest/runtests.py
testing/mozbase/mozprofile/mozprofile/__init__.py
testing/mozbase/mozprofile/mozprofile/profile.py
testing/mozbase/mozprofile/mozprofile/webapps.py
testing/mozbase/mozprofile/tests/files/webapps1.json
testing/mozbase/mozprofile/tests/files/webapps2.json
testing/mozbase/mozprofile/tests/manifest.ini
testing/mozbase/mozprofile/tests/test_webapps.py
testing/profiles/moz.build
testing/profiles/webapps_mochitest.json
--- a/build/automation.py.in
+++ b/build/automation.py.in
@@ -31,16 +31,17 @@ if os.path.isdir(mozbase):
 
 import mozcrash
 from mozscreenshot import printstatus, dump_screen
 
 
 # ---------------------------------------------------------------
 
 _DEFAULT_PREFERENCE_FILE = os.path.join(SCRIPT_DIR, 'prefs_general.js')
+_DEFAULT_APPS_FILE = os.path.join(SCRIPT_DIR, 'webapps_mochitest.json')
 
 _DEFAULT_WEB_SERVER = "127.0.0.1"
 _DEFAULT_HTTP_PORT = 8888
 _DEFAULT_SSL_PORT = 4443
 _DEFAULT_WEBSOCKET_PORT = 9988
 
 # from nsIPrincipal.idl
 _APP_STATUS_NOT_INSTALLED = 0
--- a/testing/mochitest/runtests.py
+++ b/testing/mochitest/runtests.py
@@ -1645,16 +1645,27 @@ toolbar#nav-bar {
             options.extraPrefs.append("security.sandbox.content.level=1")
         options.extraPrefs.append(
             "dom.ipc.tabs.nested.enabled=%s" %
             ('true' if options.nested_oop else 'false'))
 
         # get extensions to install
         extensions = self.getExtensionsToInstall(options)
 
+        # web apps
+        appsPath = os.path.join(
+            SCRIPT_DIR,
+            'profile_data',
+            'webapps_mochitest.json')
+        if os.path.exists(appsPath):
+            with open(appsPath) as apps_file:
+                apps = json.load(apps_file)
+        else:
+            apps = None
+
         # preferences
         preferences = [
             os.path.join(
                 SCRIPT_DIR,
                 'profile_data',
                 'prefs_general.js')]
 
         prefs = {}
@@ -1696,16 +1707,17 @@ toolbar#nav-bar {
             prefs['media.audio_loopback_dev'] = self.mediaDevices['audio']
             prefs['media.video_loopback_dev'] = self.mediaDevices['video']
 
         # create a profile
         self.profile = Profile(profile=options.profilePath,
                                addons=extensions,
                                locations=self.locations,
                                preferences=prefs,
+                               apps=apps,
                                proxy=proxy
                                )
 
         # Fix options.profilePath for legacy consumers.
         options.profilePath = self.profile.profile
 
         manifest = self.addChromeToProfile(options)
         self.copyExtraFilesToProfile(options)
--- a/testing/mozbase/mozprofile/mozprofile/__init__.py
+++ b/testing/mozbase/mozprofile/mozprofile/__init__.py
@@ -13,8 +13,9 @@ with preset preferences for those applic
 
 from addons import *
 from cli import *
 from diff import *
 from permissions import *
 from prefs import *
 from profile import *
 from view import *
+from webapps import *
--- a/testing/mozbase/mozprofile/mozprofile/profile.py
+++ b/testing/mozbase/mozprofile/mozprofile/profile.py
@@ -7,16 +7,17 @@ import time
 import tempfile
 import uuid
 
 from addons import AddonManager
 import mozfile
 from permissions import Permissions
 from prefs import Preferences
 from shutil import copytree
+from webapps import WebappCollection
 
 __all__ = ['Profile',
            'FirefoxProfile',
            'MetroFirefoxProfile',
            'ThunderbirdProfile']
 
 
 class Profile(object):
@@ -38,29 +39,31 @@ class Profile(object):
     the profile as a context manager: ::
 
       with Profile() as profile:
           # do things with the profile
           pass
       # profile.cleanup() has been called here
     """
 
-    def __init__(self, profile=None, addons=None, addon_manifests=None,
+    def __init__(self, profile=None, addons=None, addon_manifests=None, apps=None,
                  preferences=None, locations=None, proxy=None, restore=True):
         """
         :param profile: Path to the profile
         :param addons: String of one or list of addons to install
         :param addon_manifests: Manifest for addons (see http://bit.ly/17jQ7i6)
+        :param apps: Dictionary or class of webapps to install
         :param preferences: Dictionary or class of preferences
         :param locations: ServerLocations object
         :param proxy: Setup a proxy
         :param restore: Flag for removing all custom settings during cleanup
         """
         self._addons = addons
         self._addon_manifests = addon_manifests
+        self._apps = apps
         self._locations = locations
         self._proxy = proxy
 
         # Prepare additional preferences
         if preferences:
             if isinstance(preferences, dict):
                 # unordered
                 preferences = preferences.items()
@@ -108,16 +111,20 @@ class Profile(object):
         prefs_js, user_js = self.permissions.network_prefs(self._proxy)
         self.set_preferences(prefs_js, 'prefs.js')
         self.set_preferences(user_js)
 
         # handle add-on installation
         self.addon_manager = AddonManager(self.profile, restore=self.restore)
         self.addon_manager.install_addons(self._addons, self._addon_manifests)
 
+        # handle webapps
+        self.webapps = WebappCollection(profile=self.profile, apps=self._apps)
+        self.webapps.update_manifests()
+
     def __enter__(self):
         return self
 
     def __exit__(self, type, value, traceback):
         self.cleanup()
 
     def __del__(self):
         self.cleanup()
@@ -130,16 +137,18 @@ class Profile(object):
         if self.restore:
             # If copies of those class instances exist ensure we correctly
             # reset them all (see bug 934484)
             self.clean_preferences()
             if getattr(self, 'addon_manager', None) is not None:
                 self.addon_manager.clean()
             if getattr(self, 'permissions', None) is not None:
                 self.permissions.clean_db()
+            if getattr(self, 'webapps', None) is not None:
+                self.webapps.clean()
 
             # If it's a temporary profile we have to remove it
             if self.create_new:
                 mozfile.remove(self.profile)
 
     def reset(self):
         """
         reset the profile to the beginning state
new file mode 100644
--- /dev/null
+++ b/testing/mozbase/mozprofile/mozprofile/webapps.py
@@ -0,0 +1,281 @@
+# 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/.
+
+"""
+Handles installing open webapps (https://developer.mozilla.org/en-US/docs/Apps)
+to a profile. A webapp object is a dict that contains some metadata about
+the webapp and must at least include a name, description and manifestURL.
+
+Each webapp has a manifest (https://developer.mozilla.org/en-US/docs/Apps/Manifest).
+Additionally there is a separate json manifest that keeps track of the installed
+webapps, their manifestURLs and their permissions.
+"""
+
+from string import Template
+import json
+import os
+import shutil
+
+import mozfile
+
+__all__ = ["Webapp", "WebappCollection", "WebappFormatException", "APP_STATUS_NOT_INSTALLED",
+           "APP_STATUS_INSTALLED", "APP_STATUS_PRIVILEGED", "APP_STATUS_CERTIFIED"]
+
+
+# from http://hg.mozilla.org/mozilla-central/file/add0b94c2c0b/caps/idl/nsIPrincipal.idl#l163
+APP_STATUS_NOT_INSTALLED = 0
+APP_STATUS_INSTALLED = 1
+APP_STATUS_PRIVILEGED = 2
+APP_STATUS_CERTIFIED = 3
+
+
+class WebappFormatException(Exception):
+    """thrown for invalid webapp objects"""
+
+
+class Webapp(dict):
+    """A webapp definition"""
+
+    required_keys = ('name', 'description', 'manifestURL')
+
+    def __init__(self, *args, **kwargs):
+        try:
+            dict.__init__(self, *args, **kwargs)
+        except (TypeError, ValueError):
+            raise WebappFormatException("Webapp object should be an instance of type 'dict'")
+        self.validate()
+
+    def __eq__(self, other):
+        """Webapps are considered equal if they have the same name"""
+        if not isinstance(other, self.__class__):
+            return False
+        return self['name'] == other['name']
+
+    def __ne__(self, other):
+        """Webapps are considered not equal if they have different names"""
+        return not self.__eq__(other)
+
+    def validate(self):
+        # TODO some keys are required if another key has a certain value
+        for key in self.required_keys:
+            if key not in self:
+                raise WebappFormatException("Webapp object missing required key '%s'" % key)
+
+
+class WebappCollection(object):
+    """A list-like object that collects webapps and updates the webapp manifests"""
+
+    json_template = Template(""""$name": {
+  "name": "$name",
+  "origin": "$origin",
+  "installOrigin": "$origin",
+  "receipt": null,
+  "installTime": 132333986000,
+  "manifestURL": "$manifestURL",
+  "localId": $localId,
+  "id": "$name",
+  "appStatus": $appStatus,
+  "csp": "$csp"
+}""")
+
+    manifest_template = Template("""{
+  "name": "$name",
+  "csp": "$csp",
+  "description": "$description",
+  "launch_path": "/",
+  "developer": {
+    "name": "Mozilla",
+    "url": "https://mozilla.org/"
+  },
+  "permissions": [
+  ],
+  "locales": {
+    "en-US": {
+      "name": "$name",
+      "description": "$description"
+    }
+  },
+  "default_locale": "en-US",
+  "icons": {
+  }
+}
+""")
+
+    def __init__(self, profile, apps=None, json_template=None, manifest_template=None):
+        """
+        :param profile: the file path to a profile
+        :param apps: [optional] a list of webapp objects or file paths to json files describing
+          webapps
+        :param json_template: [optional] string template describing the webapp json format
+        :param manifest_template: [optional] string template describing the webapp manifest format
+        """
+        if not isinstance(profile, basestring):
+            raise TypeError("Must provide path to a profile, received '%s'" % type(profile))
+        self.profile = profile
+        self.webapps_dir = os.path.join(self.profile, 'webapps')
+        self.backup_dir = os.path.join(self.profile, '.mozprofile_backup', 'webapps')
+
+        self._apps = []
+        self._installed_apps = []
+        if apps:
+            if not isinstance(apps, (list, set, tuple)):
+                apps = [apps]
+
+            for app in apps:
+                if isinstance(app, basestring) and os.path.isfile(app):
+                    self.extend(self.read_json(app))
+                else:
+                    self.append(app)
+
+        self.json_template = json_template or self.json_template
+        self.manifest_template = manifest_template or self.manifest_template
+
+    def __getitem__(self, index):
+        return self._apps.__getitem__(index)
+
+    def __setitem__(self, index, value):
+        return self._apps.__setitem__(index, Webapp(value))
+
+    def __delitem__(self, index):
+        return self._apps.__delitem__(index)
+
+    def __len__(self):
+        return self._apps.__len__()
+
+    def __contains__(self, value):
+        return self._apps.__contains__(Webapp(value))
+
+    def append(self, value):
+        return self._apps.append(Webapp(value))
+
+    def insert(self, index, value):
+        return self._apps.insert(index, Webapp(value))
+
+    def extend(self, values):
+        return self._apps.extend([Webapp(v) for v in values])
+
+    def remove(self, value):
+        return self._apps.remove(Webapp(value))
+
+    def _write_webapps_json(self, apps):
+        contents = []
+        for app in apps:
+            contents.append(self.json_template.substitute(app))
+        contents = '{\n' + ',\n'.join(contents) + '\n}\n'
+        webapps_json_path = os.path.join(self.webapps_dir, 'webapps.json')
+        webapps_json_file = open(webapps_json_path, "w")
+        webapps_json_file.write(contents)
+        webapps_json_file.close()
+
+    def _write_webapp_manifests(self, write_apps=[], remove_apps=[]):
+        # Write manifests for installed apps
+        for app in write_apps:
+            manifest_dir = os.path.join(self.webapps_dir, app['name'])
+            manifest_path = os.path.join(manifest_dir, 'manifest.webapp')
+            if not os.path.isfile(manifest_path):
+                if not os.path.isdir(manifest_dir):
+                    os.mkdir(manifest_dir)
+                manifest = self.manifest_template.substitute(app)
+                manifest_file = open(manifest_path, "a")
+                manifest_file.write(manifest)
+                manifest_file.close()
+        # Remove manifests for removed apps
+        for app in remove_apps:
+            self._installed_apps.remove(app)
+            manifest_dir = os.path.join(self.webapps_dir, app['name'])
+            mozfile.remove(manifest_dir)
+
+    def update_manifests(self):
+        """Updates the webapp manifests with the webapps represented in this collection
+
+        If update_manifests is called a subsequent time, there could have been apps added or
+        removed to the collection in the interim. The manifests will be adjusted accordingly
+        """
+        apps_to_install = [app for app in self._apps if app not in self._installed_apps]
+        apps_to_remove = [app for app in self._installed_apps if app not in self._apps]
+        if apps_to_install == apps_to_remove == []:
+            # nothing to do
+            return
+
+        if not os.path.isdir(self.webapps_dir):
+            os.makedirs(self.webapps_dir)
+        elif not self._installed_apps:
+            shutil.copytree(self.webapps_dir, self.backup_dir)
+
+        webapps_json_path = os.path.join(self.webapps_dir, 'webapps.json')
+        webapps_json = []
+        if os.path.isfile(webapps_json_path):
+            webapps_json = self.read_json(webapps_json_path, description="description")
+            webapps_json = [a for a in webapps_json if a not in apps_to_remove]
+
+        # Iterate over apps already in webapps.json to determine the starting local
+        # id and to ensure apps are properly formatted
+        start_id = 1
+        for local_id, app in enumerate(webapps_json):
+            app['localId'] = local_id + 1
+            start_id += 1
+            if not app.get('csp'):
+                app['csp'] = ''
+            if not app.get('appStatus'):
+                app['appStatus'] = 3
+
+        # Append apps_to_install to the pre-existent apps
+        for local_id, app in enumerate(apps_to_install):
+            app['localId'] = local_id + start_id
+            # ignore if it's already installed
+            if app in webapps_json:
+                start_id -= 1
+                continue
+            webapps_json.append(app)
+            self._installed_apps.append(app)
+
+        # Write the full contents to webapps.json
+        self._write_webapps_json(webapps_json)
+
+        # Create/remove manifest file for each app.
+        self._write_webapp_manifests(apps_to_install, apps_to_remove)
+
+    def clean(self):
+        """Remove all webapps that were installed and restore profile to previous state"""
+        if self._installed_apps:
+            mozfile.remove(self.webapps_dir)
+
+        if os.path.isdir(self.backup_dir):
+            shutil.copytree(self.backup_dir, self.webapps_dir)
+            mozfile.remove(self.backup_dir)
+
+        self._apps = []
+        self._installed_apps = []
+
+    @classmethod
+    def read_json(cls, path, **defaults):
+        """Reads a json file which describes a set of webapps. The json format is either a
+        dictionary where each key represents the name of a webapp (e.g B2G format) or a list
+        of webapp objects.
+
+        :param path: Path to a json file defining webapps
+        :param defaults: Default key value pairs added to each webapp object if key doesn't exist
+
+        Returns a list of Webapp objects
+        """
+        f = open(path, 'r')
+        app_json = json.load(f)
+        f.close()
+
+        apps = []
+        if isinstance(app_json, dict):
+            for k, v in app_json.iteritems():
+                v['name'] = k
+                apps.append(v)
+        else:
+            apps = app_json
+            if not isinstance(apps, list):
+                apps = [apps]
+
+        ret = []
+        for app in apps:
+            d = defaults.copy()
+            d.update(app)
+            ret.append(Webapp(**d))
+        return ret
new file mode 100644
--- /dev/null
+++ b/testing/mozbase/mozprofile/tests/files/webapps1.json
@@ -0,0 +1,50 @@
+[{ "name": "http_example_org",
+   "csp": "",
+   "origin": "http://example.org",
+   "manifestURL": "http://example.org/manifest.webapp",
+   "description": "http://example.org App",
+   "appStatus": 1
+ },
+ { "name": "https_example_com",
+   "csp": "",
+   "origin": "https://example.com",
+   "manifestURL": "https://example.com/manifest.webapp",
+   "description": "https://example.com App",
+   "appStatus": 1
+ },
+ { "name": "http_test1_example_org",
+   "csp": "",
+   "origin": "http://test1.example.org",
+   "manifestURL": "http://test1.example.org/manifest.webapp",
+   "description": "http://test1.example.org App",
+   "appStatus": 1
+ },
+ { "name": "http_test1_example_org_8000",
+   "csp": "",
+   "origin": "http://test1.example.org:8000",
+   "manifestURL": "http://test1.example.org:8000/manifest.webapp",
+   "description": "http://test1.example.org:8000 App",
+   "appStatus": 1
+ },
+ { "name": "http_sub1_test1_example_org",
+   "csp": "",
+   "origin": "http://sub1.test1.example.org",
+   "manifestURL": "http://sub1.test1.example.org/manifest.webapp",
+   "description": "http://sub1.test1.example.org App",
+   "appStatus": 1
+ },
+ { "name": "https_example_com_privileged",
+   "csp": "",
+   "origin": "https://example.com",
+   "manifestURL": "https://example.com/manifest_priv.webapp",
+   "description": "https://example.com Privileged App",
+   "appStatus": 2
+ },
+ { "name": "https_example_com_certified",
+   "csp": "",
+   "origin": "https://example.com",
+   "manifestURL": "https://example.com/manifest_cert.webapp",
+   "description": "https://example.com Certified App",
+   "appStatus": 3
+ }
+]
new file mode 100644
--- /dev/null
+++ b/testing/mozbase/mozprofile/tests/files/webapps2.json
@@ -0,0 +1,37 @@
+{
+    "https_example_csp_certified": {
+      "csp": "default-src *; script-src 'self'; object-src 'none'; style-src 'self' 'unsafe-inline'",
+      "origin": "https://example.com",
+      "manifestURL": "https://example.com/manifest_csp_cert.webapp",
+      "description": "https://example.com certified app with manifest policy",
+      "appStatus": 3
+    },
+    "https_example_csp_installed": {
+      "csp": "default-src *; script-src 'self'; object-src 'none'; style-src 'self' 'unsafe-inline'",
+      "origin": "https://example.com",
+      "manifestURL": "https://example.com/manifest_csp_inst.webapp",
+      "description": "https://example.com installed app with manifest policy",
+      "appStatus": 1
+    },
+    "https_example_csp_privileged": {
+      "csp": "default-src *; script-src 'self'; object-src 'none'; style-src 'self' 'unsafe-inline'",
+      "origin": "https://example.com",
+      "manifestURL": "https://example.com/manifest_csp_priv.webapp",
+      "description": "https://example.com privileged app with manifest policy",
+      "appStatus": 2
+    },
+    "https_a_domain_certified": {
+      "csp": "",
+      "origin": "https://acertified.com",
+      "manifestURL": "https://acertified.com/manifest.webapp",
+      "description": "https://acertified.com certified app",
+      "appStatus": 3
+    },
+    "https_a_domain_privileged": {
+      "csp": "",
+      "origin": "https://aprivileged.com",
+      "manifestURL": "https://aprivileged.com/manifest.webapp",
+      "description": "https://aprivileged.com privileged app ",
+      "appStatus": 2
+    }
+}
--- a/testing/mozbase/mozprofile/tests/manifest.ini
+++ b/testing/mozbase/mozprofile/tests/manifest.ini
@@ -1,11 +1,12 @@
 [addonid.py]
 [server_locations.py]
 [test_preferences.py]
 [permissions.py]
 [bug758250.py]
 [test_nonce.py]
 [bug785146.py]
 [test_clone_cleanup.py]
+[test_webapps.py]
 [test_profile.py]
 [test_profile_view.py]
 [test_addons.py]
new file mode 100644
--- /dev/null
+++ b/testing/mozbase/mozprofile/tests/test_webapps.py
@@ -0,0 +1,202 @@
+#!/usr/bin/env python
+
+"""
+test installing and managing webapps in a profile
+"""
+
+import os
+import shutil
+import unittest
+from tempfile import mkdtemp
+
+from mozprofile.webapps import WebappCollection, Webapp, WebappFormatException
+
+here = os.path.dirname(os.path.abspath(__file__))
+
+
+class WebappTest(unittest.TestCase):
+    """Tests reading, installing and cleaning webapps
+    from a profile.
+    """
+    manifest_path_1 = os.path.join(here, 'files', 'webapps1.json')
+    manifest_path_2 = os.path.join(here, 'files', 'webapps2.json')
+
+    def setUp(self):
+        self.profile = mkdtemp(prefix='test_webapp')
+        self.webapps_dir = os.path.join(self.profile, 'webapps')
+        self.webapps_json_path = os.path.join(self.webapps_dir, 'webapps.json')
+
+    def tearDown(self):
+        shutil.rmtree(self.profile)
+
+    def test_read_json_manifest(self):
+        """Tests WebappCollection.read_json"""
+        # Parse a list of webapp objects and verify it worked
+        manifest_json_1 = WebappCollection.read_json(self.manifest_path_1)
+        self.assertEqual(len(manifest_json_1), 7)
+        for app in manifest_json_1:
+            self.assertIsInstance(app, Webapp)
+            for key in Webapp.required_keys:
+                self.assertIn(key, app)
+
+        # Parse a dictionary of webapp objects and verify it worked
+        manifest_json_2 = WebappCollection.read_json(self.manifest_path_2)
+        self.assertEqual(len(manifest_json_2), 5)
+        for app in manifest_json_2:
+            self.assertIsInstance(app, Webapp)
+            for key in Webapp.required_keys:
+                self.assertIn(key, app)
+
+    def test_invalid_webapp(self):
+        """Tests a webapp with a missing required key"""
+        webapps = WebappCollection(self.profile)
+        # Missing the required key "description", exception should be raised
+        self.assertRaises(WebappFormatException, webapps.append, {'name': 'foo'})
+
+    def test_webapp_collection(self):
+        """Tests the methods of the WebappCollection object"""
+        webapp_1 = {'name': 'test_app_1',
+                    'description': 'a description',
+                    'manifestURL': 'http://example.com/1/manifest.webapp',
+                    'appStatus': 1}
+
+        webapp_2 = {'name': 'test_app_2',
+                    'description': 'another description',
+                    'manifestURL': 'http://example.com/2/manifest.webapp',
+                    'appStatus': 2}
+
+        webapp_3 = {'name': 'test_app_2',
+                    'description': 'a third description',
+                    'manifestURL': 'http://example.com/3/manifest.webapp',
+                    'appStatus': 3}
+
+        webapps = WebappCollection(self.profile)
+        self.assertEqual(len(webapps), 0)
+
+        # WebappCollection should behave like a list
+        def invalid_index():
+            webapps[0]
+        self.assertRaises(IndexError, invalid_index)
+
+        # Append a webapp object
+        webapps.append(webapp_1)
+        self.assertTrue(len(webapps), 1)
+        self.assertIsInstance(webapps[0], Webapp)
+        self.assertEqual(len(webapps[0]), len(webapp_1))
+        self.assertEqual(len(set(webapps[0].items()) & set(webapp_1.items())), len(webapp_1))
+
+        # Remove a webapp object
+        webapps.remove(webapp_1)
+        self.assertEqual(len(webapps), 0)
+
+        # Extend a list of webapp objects
+        webapps.extend([webapp_1, webapp_2])
+        self.assertEqual(len(webapps), 2)
+        self.assertTrue(webapp_1 in webapps)
+        self.assertTrue(webapp_2 in webapps)
+        self.assertNotEquals(webapps[0], webapps[1])
+
+        # Insert a webapp object
+        webapps.insert(1, webapp_3)
+        self.assertEqual(len(webapps), 3)
+        self.assertEqual(webapps[1], webapps[2])
+        for app in webapps:
+            self.assertIsInstance(app, Webapp)
+
+        # Assigning an invalid type (must be accepted by the dict() constructor) should throw
+        def invalid_type():
+            webapps[2] = 1
+        self.assertRaises(WebappFormatException, invalid_type)
+
+    def test_install_webapps(self):
+        """Test installing webapps into a profile that has no prior webapps"""
+        webapps = WebappCollection(self.profile, apps=self.manifest_path_1)
+        self.assertFalse(os.path.exists(self.webapps_dir))
+
+        # update the webapp manifests for the first time
+        webapps.update_manifests()
+        self.assertFalse(os.path.isdir(os.path.join(self.profile, webapps.backup_dir)))
+        self.assertTrue(os.path.isfile(self.webapps_json_path))
+
+        webapps_json = webapps.read_json(self.webapps_json_path, description="fake description")
+        self.assertEqual(len(webapps_json), 7)
+        for app in webapps_json:
+            self.assertIsInstance(app, Webapp)
+
+        manifest_json_1 = webapps.read_json(self.manifest_path_1)
+        manifest_json_2 = webapps.read_json(self.manifest_path_2)
+        self.assertEqual(len(webapps_json), len(manifest_json_1))
+        for app in webapps_json:
+            self.assertTrue(app in manifest_json_1)
+
+        # Remove one of the webapps from WebappCollection after it got installed
+        removed_app = manifest_json_1[2]
+        webapps.remove(removed_app)
+        # Add new webapps to the collection
+        webapps.extend(manifest_json_2)
+
+        # update the webapp manifests a second time
+        webapps.update_manifests()
+        self.assertFalse(os.path.isdir(os.path.join(self.profile, webapps.backup_dir)))
+        self.assertTrue(os.path.isfile(self.webapps_json_path))
+
+        webapps_json = webapps.read_json(self.webapps_json_path, description="a description")
+        self.assertEqual(len(webapps_json), 11)
+
+        # The new apps should be added
+        for app in webapps_json:
+            self.assertIsInstance(app, Webapp)
+            self.assertTrue(os.path.isfile(os.path.join(self.webapps_dir, app['name'],
+                                                        'manifest.webapp')))
+        # The removed app should not exist in the manifest
+        self.assertNotIn(removed_app, webapps_json)
+        self.assertFalse(os.path.exists(os.path.join(self.webapps_dir, removed_app['name'])))
+
+        # Cleaning should delete the webapps directory entirely
+        # since there was nothing there before
+        webapps.clean()
+        self.assertFalse(os.path.isdir(self.webapps_dir))
+
+    def test_install_webapps_preexisting(self):
+        """Tests installing webapps when the webapps directory already exists"""
+        manifest_json_2 = WebappCollection.read_json(self.manifest_path_2)
+
+        # Synthesize a pre-existing webapps directory
+        os.mkdir(self.webapps_dir)
+        shutil.copyfile(self.manifest_path_2, self.webapps_json_path)
+        for app in manifest_json_2:
+            app_path = os.path.join(self.webapps_dir, app['name'])
+            os.mkdir(app_path)
+            f = open(os.path.join(app_path, 'manifest.webapp'), 'w')
+            f.close()
+
+        webapps = WebappCollection(self.profile, apps=self.manifest_path_1)
+        self.assertTrue(os.path.exists(self.webapps_dir))
+
+        # update webapp manifests for the first time
+        webapps.update_manifests()
+        # A backup should be created
+        self.assertTrue(os.path.isdir(os.path.join(self.profile, webapps.backup_dir)))
+
+        # Both manifests should remain installed
+        webapps_json = webapps.read_json(self.webapps_json_path, description='a fake description')
+        self.assertEqual(len(webapps_json), 12)
+        for app in webapps_json:
+            self.assertIsInstance(app, Webapp)
+            self.assertTrue(os.path.isfile(os.path.join(self.webapps_dir, app['name'],
+                                                        'manifest.webapp')))
+
+        # Upon cleaning the backup should be restored
+        webapps.clean()
+        self.assertFalse(os.path.isdir(os.path.join(self.profile, webapps.backup_dir)))
+
+        # The original webapps should still be installed
+        webapps_json = webapps.read_json(self.webapps_json_path)
+        for app in webapps_json:
+            self.assertIsInstance(app, Webapp)
+            self.assertTrue(os.path.isfile(os.path.join(self.webapps_dir, app['name'],
+                                                        'manifest.webapp')))
+        self.assertEqual(webapps_json, manifest_json_2)
+
+if __name__ == '__main__':
+    unittest.main()
--- a/testing/profiles/moz.build
+++ b/testing/profiles/moz.build
@@ -1,12 +1,13 @@
 # -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
 # vim: set filetype=python:
 # 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/.
 
 mochitest_profile_files = [
     'prefs_general.js',
+    'webapps_mochitest.json',
 ]
 
 TEST_HARNESS_FILES.testing.mochitest.profile_data += mochitest_profile_files
 TEST_HARNESS_FILES['web-platform'].prefs += mochitest_profile_files
new file mode 100644
--- /dev/null
+++ b/testing/profiles/webapps_mochitest.json
@@ -0,0 +1,114 @@
+[
+  {
+    "name": "http_example_org",
+    "csp": "",
+    "origin": "http://example.org",
+    "manifestURL": "http://example.org/manifest.webapp",
+    "description": "http://example.org App",
+    "appStatus": 1
+  },
+  {
+    "name": "https_example_com",
+    "csp": "",
+    "origin": "https://example.com",
+    "manifestURL": "https://example.com/manifest.webapp",
+    "description": "https://example.com App",
+    "appStatus": 1
+  },
+  {
+    "name": "http_test1_example_org",
+    "csp": "",
+    "origin": "http://test1.example.org",
+    "manifestURL": "http://test1.example.org/manifest.webapp",
+    "description": "http://test1.example.org App",
+    "appStatus": 1
+  },
+  {
+    "name": "http_test1_example_org_8000",
+    "csp": "",
+    "origin": "http://test1.example.org:8000",
+    "manifestURL": "http://test1.example.org:8000/manifest.webapp",
+    "description": "http://test1.example.org:8000 App",
+    "appStatus": 1
+  },
+  {
+    "name": "http_sub1_test1_example_org",
+    "csp": "",
+    "origin": "http://sub1.test1.example.org",
+    "manifestURL": "http://sub1.test1.example.org/manifest.webapp",
+    "description": "http://sub1.test1.example.org App",
+    "appStatus": 1
+  },
+  {
+    "name": "https_example_com_privileged",
+    "csp": "",
+    "origin": "https://example.com",
+    "manifestURL": "https://example.com/manifest_priv.webapp",
+    "description": "https://example.com Privileged App",
+    "appStatus": 2
+  },
+  {
+    "name": "https_example_com_certified",
+    "csp": "",
+    "origin": "https://example.com",
+    "manifestURL": "https://example.com/manifest_cert.webapp",
+    "description": "https://example.com Certified App",
+    "appStatus": 3
+  },
+  {
+    "name": "https_example_csp_certified",
+    "csp": "default-src *; script-src 'self'; object-src 'none'; style-src 'self' 'unsafe-inline'",
+    "origin": "https://example.com",
+    "manifestURL": "https://example.com/manifest_csp_cert.webapp",
+    "description": "https://example.com Certified App with manifest policy",
+    "appStatus": 3
+  },
+  {
+    "name": "https_example_csp_installed",
+    "csp": "default-src *; script-src 'self'; object-src 'none'; style-src 'self' 'unsafe-inline'",
+    "origin": "https://example.com",
+    "manifestURL": "https://example.com/manifest_csp_inst.webapp",
+    "description": "https://example.com Installed App with manifest policy",
+    "appStatus": 1
+  },
+  {
+    "name": "https_example_csp_privileged",
+    "csp": "default-src *; script-src 'self'; object-src 'none'; style-src 'self' 'unsafe-inline'",
+    "origin": "https://example.com",
+    "manifestURL": "https://example.com/manifest_csp_priv.webapp",
+    "description": "https://example.com Privileged App with manifest policy",
+    "appStatus": 2
+  },
+  {
+    "name": "https_a_domain_certified",
+    "csp": "",
+    "origin": "https://acertified.com",
+    "manifestURL": "https://acertified.com/manifest.webapp",
+    "description": "https://acertified.com Certified App",
+    "appStatus": 3
+  },
+  {
+    "name": "https_a_domain_privileged",
+    "csp": "",
+    "origin": "https://aprivileged.com",
+    "manifestURL": "https://aprivileged.com/manifest.webapp",
+    "description": "https://aprivileged.com Privileged App ",
+    "appStatus": 2
+  },
+  {
+    "name": "test_desktop_hosted_launch",
+    "csp": "",
+    "origin": "http://127.0.0.1:8888/",
+    "manifestURL": "http://127.0.0.1:8888/sample.manifest",
+    "description": "Hosted app",
+    "appStatus": 1
+  },
+  {
+    "name": "test_desktop_packaged_launch",
+    "csp": "",
+    "origin": "app://test_desktop_packaged_launch",
+    "manifestURL": "http://127.0.0.1:8888/sample.manifest",
+    "description": "Packaged App",
+    "appStatus": 2
+  }
+]