Index: tests/test_packagerWebExt.py
diff --git a/tests/test_packagerWebExt.py b/tests/test_packagerWebExt.py
new file mode 100644
index 0000000000000000000000000000000000000000..3dc3ba43411abc330871a387f2dd16b397d8fce7
--- /dev/null
+++ b/tests/test_packagerWebExt.py
@@ -0,0 +1,477 @@
+# 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/.
+
+import os
+import shutil
+import zipfile
+import json
+import re
+from struct import unpack
+import difflib
+
+import pytest
+from Crypto.Hash import SHA
+from Crypto.PublicKey import RSA
+from Crypto.Signature import PKCS1_v1_5
+from xml.etree import ElementTree
+
+
+from buildtools import packager
+from buildtools.packagerChrome import defaultLocale
+from buildtools.build import processArgs
+
+LOCALES_MODULE = {
+    'test.Foobar': {
+        'message': 'Ensuring dict-copy from modules for $domain$',
+        'description': 'test description',
+        'placeholders': {'content': '$1', 'example': 'www.adblockplus.org'}
+    }
+}
+
+ALL_LANGUAGES = ['en_US', 'de', 'it']
+
+MESSAGES_EN_US = json.dumps({
+    'name': {'message': 'Adblock Plus'},
+    'name_devbuild': {'message': 'devbuild-marker'},
+    'description': {
+        'message': 'Adblock Plus is the most popular ad blocker ever, '
+                   'and also supports websites by not blocking '
+                   'unobstrusive ads by default (configurable).'
+    },
+})
+
+
+class Content(object):
+    """Base class for a unified ZipFile / Directory interface.
+
+    Base class for providing a unified context manager interface for
+    accessing files. This class is subclassed by ZipContent and DirContent,
+    which provide the additional methods "namelist()" and "read(path)".
+    """
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, exc_type, exc_value, exc_tb):
+        self._close()
+
+
+class ZipContent(Content):
+    """Provide a unified context manager for ZipFile access.
+
+    Inherits the context manager API from Content.
+    If desired, the specified ZipFile is deleted on exiting the manager.
+    """
+
+    def __init__(self, zip_path, delete_on_close=True):
+        """Construct ZipContent object handling the file <zip_path>.
+
+        The parameter 'delete_on_close' causes the context manager to
+        delete the handled ZipFile (specified by zip_path) if set to
+        True (default).
+        """
+        self._zip_path = zip_path
+        self._zip_file = zipfile.ZipFile(zip_path)
+        self._delete_on_close = delete_on_close
+        super(ZipContent, self).__init__()
+
+    def _close(self):
+        self._zip_file.close()
+        if self._delete_on_close:
+            # if specified, delete the handled file
+            os.remove(self._zip_path)
+
+    def namelist(self):
+        return self._zip_file.namelist()
+
+    def read(self, path):
+        return self._zip_file.read(path)
+
+
+class DirContent(Content):
+    """Provides a unified context manager for directory access.
+
+    Inherits the context managet API from Content.
+    """
+
+    def __init__(self, path):
+        """Construct a DirContent object handling <path>."""
+        self._path = path
+        super(DirContent, self).__init__()
+
+    def _close(self):
+        pass
+
+    def namelist(self):
+        """Generate a list of filenames."""
+        result = []
+        for parent, directories, files in os.walk(self._path):
+            for filename in files:
+                file_path = os.path.join(parent, filename)
+                rel_path = os.path.relpath(file_path, self._path)
+                result.append(rel_path)
+        return result
+
+    def read(self, path):
+        content = None
+        with open(os.path.join(self._path, path)) as fp:
+            content = fp.read()
+        return content
+
+
+def copy_metadata(filename, tmpdir):
+    """Copy the used metadata to the used temporary directory."""
+    path = os.path.join(os.path.dirname(__file__), filename)
+    destination = str(tmpdir.join(filename))
+    shutil.copy(path, destination)
+
+
+def run_webext_build(ext_type, build_opt, srcdir, buildnum=None, keyfile=None):
+    """Run a build process."""
+    if build_opt == 'build':
+        build_args = ['build']
+    elif build_opt == 'release':
+        build_args = ['build', '-r']
+    else:
+        build_args = ['devenv']
+
+    args = ['build.py', '-t', ext_type] + build_args
+
+    if keyfile:
+        args += ['-k', keyfile]
+    if buildnum:
+        args += ['-b', buildnum]
+
+    processArgs(str(srcdir), args)
+
+
+def locale_files(languages, rootfolder, srcdir):
+    """Generate example locales.
+
+    languages: tuple, list or set of languages to include
+    rootdir: folder-name to create the locale-folders in
+    """
+    for lang in languages:
+        subfolders = rootfolder.split(os.pathsep) + [lang, 'messages.json']
+        json_file = srcdir.ensure(*subfolders)
+        if lang == defaultLocale:
+            json_file.write(MESSAGES_EN_US)
+        else:
+            json_file.write('{}')
+
+
+def assert_all_locales_present(package, prefix):
+    names = {x for x in package.namelist() if
+             x.startswith(os.path.join(prefix, '_locales'))}
+    assert names == {
+        os.path.join(prefix, '_locales', lang, 'messages.json')
+        for lang in ALL_LANGUAGES
+    }
+
+
+@pytest.fixture
+def srcdir(tmpdir):
+    """Source directory for building the package."""
+    for size in ['44', '50', '150']:
+        path = tmpdir.join('chrome', 'icons', 'abp-{}.png'.format(size))
+        path.write(size, ensure=True)
+
+    tmpdir.join('bar.json').write(json.dumps({}))
+    return tmpdir
+
+
+@pytest.fixture
+def locale_modules(tmpdir):
+    mod_dir = tmpdir.mkdir('_modules')
+    lang_dir = mod_dir.mkdir('en_US')
+    lang_dir.join('module.json').write(json.dumps(LOCALES_MODULE))
+
+
+@pytest.fixture
+def icons(srcdir):
+    icons_dir = srcdir.mkdir('icons')
+    for filename in ['abp-16.png', 'abp-19.png', 'abp-53.png']:
+        shutil.copy(
+            os.path.join(os.path.dirname(__file__), filename),
+            os.path.join(str(icons_dir), filename),
+        )
+
+
+@pytest.fixture
+def all_lang_locales(tmpdir):
+    return locale_files(ALL_LANGUAGES, '_locales', tmpdir)
+
+
+@pytest.fixture
+def chrome_metadata(tmpdir):
+    filename = 'metadata.chrome'
+    copy_metadata(filename, tmpdir)
+
+
+@pytest.fixture
+def gecko_webext_metadata(tmpdir, chrome_metadata):
+    filename = 'metadata.gecko'
+    copy_metadata(filename, tmpdir)
+
+
+@pytest.fixture
+def edge_metadata(tmpdir):
+    filename = 'metadata.edge'
+    copy_metadata(filename, tmpdir)
+
+    return packager.readMetadata(str(tmpdir), 'edge')
+
+
+@pytest.fixture
+def keyfile(tmpdir):
+    """Test-privatekey for signing chrome release-package."""
+    return os.path.join(os.path.dirname(__file__), 'chrome_rsa.pem')
+
+
+@pytest.fixture
+def lib_files(tmpdir):
+    files = packager.Files(['lib'], set())
+    files['ext/a.js'] = 'var bar;'
+    files['lib/b.js'] = 'var foo;'
+
+    tmpdir.mkdir('lib').join('b.js').write(files['lib/b.js'])
+    tmpdir.mkdir('ext').join('a.js').write(files['ext/a.js'])
+
+    return files
+
+
+def comparable_xml(xml):
+    """Create a nonambiguous representation of a given XML tree."""
+    def get_leafs_string(tree):
+        """Recursively build a string representing all xml leaf-nodes."""
+        root_str = '{}|{}|{}'.format(tree.tag, tree.tail, tree.text).strip()
+        result = []
+
+        if len(tree) > 0:
+            for subtree in tree:
+                for leaf in get_leafs_string(subtree):
+                    result.append('{}__{}'.format(root_str, leaf))
+        for k, v in tree.attrib.items():
+            result.append('{}__{}:{}'.format(root_str, k, v))
+        else:
+            result.append(root_str)
+        return result
+
+    # XML data itself shall not be sorted, hence we can safely sort
+    # our string representations in order to produce a valid unified diff.
+    return sorted(get_leafs_string(ElementTree.fromstring(xml)))
+
+
+def comparable_json(json_string):
+    """Create a nonambiguous representation of a given JSON string."""
+    return json.dumps(
+        json.loads(json_string), sort_keys=True, indent=0
+    ).split('\n')
+
+
+def assert_manifest_content(manifest, expected_path):
+    extension = os.path.basename(expected_path).split('.')[-1]
+
+    with open(expected_path, 'r') as fp:
+        if extension == 'xml':
+            generated = comparable_xml(manifest)
+            expected = comparable_xml(fp.read())
+        else:
+            generated = comparable_json(manifest)
+            expected = comparable_json(fp.read())
+
+    diff = list(difflib.unified_diff(generated, expected, n=0))
+    assert len(diff) == 0, '\n'.join(diff)
+
+
+def assert_webpack_bundle(package, prefix, excluded=False):
+    libfoo = package.read(os.path.join(prefix, 'lib/foo.js'))
+    libfoomap = package.read(os.path.join(prefix, 'lib/foo.js.map'))
+
+    assert 'var bar;' in libfoo
+    assert 'webpack:///./ext/a.js' in libfoomap
+
+    assert ('var foo;' in libfoo) != excluded
+    assert ('webpack:///./lib/b.js' in libfoomap) != excluded
+
+
+def assert_devenv_scripts(package, devenv):
+    manifest = json.loads(package.read('manifest.json'))
+    filenames = package.namelist()
+    scripts = [
+        'ext/common.js',
+        'ext/background.js',
+    ]
+
+    assert ('qunit/index.html' in filenames) == devenv
+    assert ('devenvPoller__.js' in filenames) == devenv
+    assert ('devenvVersion__' in filenames) == devenv
+
+    if devenv:
+        assert '../ext/common.js' in package.read('qunit/index.html')
+        assert '../ext/background.js' in package.read('qunit/index.html')
+
+        assert set(manifest['background']['scripts']) == set(
+            scripts + ['devenvPoller__.js']
+        )
+    else:
+        assert set(manifest['background']['scripts']) == set(scripts)
+
+
+def assert_base_files(package, platform):
+    filenames = set(package.namelist())
+
+    if platform in {'chrome', 'gecko'}:
+        assert 'bar.json' in filenames
+        assert 'manifest.json' in filenames
+        assert 'lib/foo.js' in filenames
+        assert 'foo/logo_50.png' in filenames
+        assert 'icons/logo_150.png' in filenames
+    else:
+        assert 'AppxManifest.xml' in filenames
+        assert 'AppxBlockMap.xml' in filenames
+        assert '[Content_Types].xml' in filenames
+
+        assert 'Extension/bar.json' in filenames
+        assert 'Extension/lib/foo.js' in filenames
+
+        assert package.read('Assets/logo_44.png') == '44'
+        assert package.read('Extension/icons/abp-44.png') == '44'
+
+
+def assert_chrome_signature(filename, keyfile):
+    with open(filename, 'r') as fp:
+        content = fp.read()
+
+    _, _, l_pubkey, l_signature = unpack('<4sIII', content[:16])
+    signature = content[16 + l_pubkey: 16 + l_pubkey + l_signature]
+
+    digest = SHA.new()
+    with open(keyfile, 'r') as fp:
+        rsa_key = RSA.importKey(fp.read())
+
+    signer = PKCS1_v1_5.new(rsa_key)
+
+    digest.update(content[16 + l_pubkey + l_signature:])
+    assert signer.verify(digest, signature)
+
+
+def assert_locale_upfix(package):
+    translations = [
+        json.loads(package.read('_locales/{}/messages.json'.format(lang)))
+        for lang in ALL_LANGUAGES
+    ]
+
+    manifest = package.read('manifest.json')
+
+    # Chrome Web Store requires descriptive translations to be present in
+    # every language.
+    for match in re.finditer(r'__MSG_(\S+)__', manifest):
+        name = match.group(1)
+
+        for other in translations[1:]:
+            assert translations[0][name]['message'] == other[name]['message']
+
+
+@pytest.mark.usefixtures(
+    'all_lang_locales',
+    'locale_modules',
+    'icons',
+    'lib_files',
+    'chrome_metadata',
+    'gecko_webext_metadata',
+    'edge_metadata',
+)
+@pytest.mark.parametrize('platform,dev_build_release,buildnum', [
+    ('chrome', 'build', '1337'),
+    ('chrome', 'devenv', None),
+    ('chrome', 'release', None),
+    ('gecko', 'build', '1337'),
+    ('gecko', 'devenv', None),
+    ('gecko', 'release', None),
+    ('edge', 'build', '1337'),
+    pytest.param('edge', 'devenv', None, marks=pytest.mark.xfail),
+    ('edge', 'release', None),
+])
+def test_build_webext(platform, dev_build_release, keyfile, tmpdir, srcdir,
+                      buildnum, capsys):
+    release = dev_build_release == 'release'
+    devenv = dev_build_release == 'devenv'
+
+    if platform == 'chrome' and release:
+        key = keyfile
+    else:
+        key = None
+
+    manifests = {
+        'gecko': [('', 'manifest', 'json')],
+        'chrome': [('', 'manifest', 'json')],
+        'edge': [('', 'AppxManifest', 'xml'),
+                 ('Extension', 'manifest', 'json')],
+    }
+
+    filenames = {
+        'gecko': 'adblockplusfirefox-1.2.3{}.xpi',
+        'chrome': 'adblockpluschrome-1.2.3{{}}.{}'.format(
+            {True: 'crx', False: 'zip'}[release]
+        ),
+        'edge': 'adblockplusedge-1.2.3{}.appx',
+    }
+
+    if platform == 'edge':
+        prefix = 'Extension'
+    else:
+        prefix = ''
+
+    run_webext_build(platform, dev_build_release, srcdir, buildnum=buildnum,
+                     keyfile=key)
+
+    # The makeIcons() in packagerChrome.py should warn about non-square
+    # icons via stderr.
+    if platform in {'chrome', 'gecko'}:
+        out, err = capsys.readouterr()
+        assert 'icon should be square' in err
+
+    if devenv:
+        content_class = DirContent
+        out_file_path = os.path.join(str(srcdir), 'devenv.' + platform)
+    else:
+        content_class = ZipContent
+
+        if release:
+            add_version = ''
+        else:
+            add_version = '.' + buildnum
+
+        out_file = filenames[platform].format(add_version)
+
+        out_file_path = os.path.abspath(os.path.join(
+            os.path.dirname(__file__), os.pardir, out_file))
+        assert os.path.exists(out_file_path)
+
+    if release and platform == 'chrome':
+        assert_chrome_signature(out_file_path, keyfile)
+
+    with content_class(out_file_path) as package:
+        assert_base_files(package, platform)
+        assert_all_locales_present(package, prefix)
+        assert_webpack_bundle(package, prefix, platform == 'gecko')
+
+        if platform == 'chrome':
+            assert_locale_upfix(package)
+        if platform in {'chrome', 'gecko'}:
+            assert_devenv_scripts(package, devenv)
+
+        for folder, name, ext in manifests[platform]:
+            filename = '{{}}_{}_{}.{{}}'.format(platform, dev_build_release)
+            expected = os.path.join(
+                os.path.dirname(__file__),
+                'expecteddata',
+                filename.format(name, ext),
+            )
+
+            assert_manifest_content(
+                package.read(os.path.join(folder, '{}.{}'.format(name, ext))),
+                expected,
+            )
