Backed out changeset de975fd9cbf6 (bug 1318879) for mass mochitest failures. r=backout on a CLOSED TREE
authorSebastian Hengst <archaeopteryx@coole-files.de>
Mon, 21 Nov 2016 17:32:30 +0100
changeset 323710 acf3c2497b43ad6537bfbbd91d23eaaa5302afca
parent 323709 bd8fc8fe4a9de47628c22ae1ed87cf9c9029acf8
child 323711 edcd2fb871241676b5b51ca778194ab795deddd0
push id21
push usermaklebus@msu.edu
push dateThu, 01 Dec 2016 06:22:08 +0000
reviewersbackout
bugs1318879
milestone53.0a1
backs outde975fd9cbf654f2b1c17db01a7544995da65611
Backed out changeset de975fd9cbf6 (bug 1318879) for mass mochitest failures. r=backout on a CLOSED TREE
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
--- 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()