Rietveld Code Review Tool
Help | Bug tracker | Discussion group | Source code

Side by Side Diff: tests/test_packagerWebExt.py

Issue 29501558: Issue 5383 - Add tests for the Chrome and Firefox packagers (Closed)
Patch Set: Refactoring commands Created Oct. 20, 2017, 11:51 a.m.
Left:
Right:
Use n/p to move between diff chunks; N/P to move between comments.
Jump to:
View unified diff | Download patch
OLDNEW
(Empty)
1 # This Source Code Form is subject to the terms of the Mozilla Public
2 # License, v. 2.0. If a copy of the MPL was not distributed with this
3 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
4
5 import os
6 import shutil
7 import zipfile
8 import json
9 import re
10 from struct import unpack
11 import difflib
12
13 import pytest
14 from Crypto.Hash import SHA
15 from Crypto.PublicKey import RSA
16 from Crypto.Signature import PKCS1_v1_5
17 from xml.etree import ElementTree
Vasily Kuznetsov 2017/10/21 17:41:10 The imports should be sorted+grouped in the follow
tlucas 2017/10/22 11:19:13 Done.
18
19 from buildtools import packager
20 from buildtools.packagerChrome import defaultLocale
21 from buildtools.build import processArgs
22
23 LOCALES_MODULE = {
24 'test.Foobar': {
25 'message': 'Ensuring dict-copy from modules for $domain$',
26 'description': 'test description',
27 'placeholders': {'content': '$1', 'example': 'www.adblockplus.org'}
28 }
29 }
30
31 ALL_LANGUAGES = ['en_US', 'de', 'it']
32
33 MESSAGES_EN_US = json.dumps({
34 'name': {'message': 'Adblock Plus'},
35 'name_devbuild': {'message': 'devbuild-marker'},
36 'description': {
37 'message': 'Adblock Plus is the most popular ad blocker ever, '
38 'and also supports websites by not blocking '
39 'unobstrusive ads by default (configurable).'
40 },
41 })
42
43
44 class Content(object):
45 """Base class for a unified ZipFile / Directory interface.
46
47 Base class for providing a unified context manager interface for
48 accessing files. This class is subclassed by ZipContent and DirContent,
49 which provide the additional methods "namelist()" and "read(path)".
50 """
51
52 def __enter__(self):
53 return self
54
55 def __exit__(self, exc_type, exc_value, exc_tb):
56 self._close()
57
58
59 class ZipContent(Content):
60 """Provide a unified context manager for ZipFile access.
61
62 Inherits the context manager API from Content.
63 If desired, the specified ZipFile is deleted on exiting the manager.
64 """
65
66 def __init__(self, zip_path, delete_on_close=True):
67 """Construct ZipContent object handling the file <zip_path>.
68
69 The parameter 'delete_on_close' causes the context manager to
70 delete the handled ZipFile (specified by zip_path) if set to
71 True (default).
72 """
73 self._zip_path = zip_path
74 self._zip_file = zipfile.ZipFile(zip_path)
75 self._delete_on_close = delete_on_close
76 super(ZipContent, self).__init__()
77
78 def _close(self):
79 self._zip_file.close()
80 if self._delete_on_close:
81 # if specified, delete the handled file
82 os.remove(self._zip_path)
83
84 def namelist(self):
85 return self._zip_file.namelist()
86
87 def read(self, path):
88 return self._zip_file.read(path)
89
90
91 class DirContent(Content):
92 """Provides a unified context manager for directory access.
93
94 Inherits the context managet API from Content.
95 """
96
97 def __init__(self, path):
98 """Construct a DirContent object handling <path>."""
99 self._path = path
100 super(DirContent, self).__init__()
101
102 def _close(self):
103 pass
104
105 def namelist(self):
106 """Generate a list of filenames."""
107 result = []
108 for parent, directories, files in os.walk(self._path):
109 for filename in files:
110 file_path = os.path.join(parent, filename)
111 rel_path = os.path.relpath(file_path, self._path)
112 result.append(rel_path)
113 return result
114
115 def read(self, path):
116 content = None
117 with open(os.path.join(self._path, path)) as fp:
118 content = fp.read()
119 return content
120
121
122 def copy_metadata(filename, tmpdir):
123 """Copy the used metadata to the used temporary directory."""
124 path = os.path.join(os.path.dirname(__file__), filename)
125 destination = str(tmpdir.join(filename))
126 shutil.copy(path, destination)
127
128
129 def run_webext_build(ext_type, build_opt, srcdir, keyfile=None):
130 """Run a build process."""
131 cmd_mapping = {
132 'devenv': ['devenv'],
133 'development_build': ['build', '-b', '1337'],
134 'release_build': ['build', '-r'],
135 }
136
137 args = ['build.py', '-t', ext_type] + cmd_mapping[build_opt]
138
139 if keyfile:
140 args += ['-k', keyfile]
141
142 processArgs(str(srcdir), args)
143
144
145 def locale_files(languages, rootfolder, srcdir):
146 """Generate example locales.
147
148 languages: tuple, list or set of languages to include
149 rootdir: folder-name to create the locale-folders in
150 """
151 for lang in languages:
152 subfolders = rootfolder.split(os.pathsep) + [lang, 'messages.json']
153 json_file = srcdir.ensure(*subfolders)
154 if lang == defaultLocale:
155 json_file.write(MESSAGES_EN_US)
156 else:
157 json_file.write('{}')
158
159
160 def assert_all_locales_present(package, prefix):
161 names = {x for x in package.namelist() if
162 x.startswith(os.path.join(prefix, '_locales'))}
163 assert names == {
164 os.path.join(prefix, '_locales', lang, 'messages.json')
165 for lang in ALL_LANGUAGES
166 }
167
168
169 @pytest.fixture
170 def srcdir(tmpdir):
171 """Source directory for building the package."""
172 for size in ['44', '50', '150']:
173 path = tmpdir.join('chrome', 'icons', 'abp-{}.png'.format(size))
174 path.write(size, ensure=True)
175
176 tmpdir.join('bar.json').write(json.dumps({}))
177 return tmpdir
178
179
180 @pytest.fixture
181 def locale_modules(tmpdir):
182 mod_dir = tmpdir.mkdir('_modules')
183 lang_dir = mod_dir.mkdir('en_US')
184 lang_dir.join('module.json').write(json.dumps(LOCALES_MODULE))
185
186
187 @pytest.fixture
188 def icons(srcdir):
189 icons_dir = srcdir.mkdir('icons')
190 for filename in ['abp-16.png', 'abp-19.png', 'abp-53.png']:
191 shutil.copy(
192 os.path.join(os.path.dirname(__file__), filename),
193 os.path.join(str(icons_dir), filename),
194 )
195
196
197 @pytest.fixture
198 def all_lang_locales(tmpdir):
199 return locale_files(ALL_LANGUAGES, '_locales', tmpdir)
200
201
202 @pytest.fixture
203 def chrome_metadata(tmpdir):
204 filename = 'metadata.chrome'
205 copy_metadata(filename, tmpdir)
206
207
208 @pytest.fixture
209 def gecko_webext_metadata(tmpdir, chrome_metadata):
210 filename = 'metadata.gecko'
211 copy_metadata(filename, tmpdir)
212
213
214 @pytest.fixture
215 def edge_metadata(tmpdir):
216 filename = 'metadata.edge'
217 copy_metadata(filename, tmpdir)
218
219 return packager.readMetadata(str(tmpdir), 'edge')
220
221
222 @pytest.fixture
223 def keyfile(tmpdir):
224 """Test-privatekey for signing chrome release-package."""
225 return os.path.join(os.path.dirname(__file__), 'chrome_rsa.pem')
226
227
228 @pytest.fixture
229 def lib_files(tmpdir):
230 files = packager.Files(['lib'], set())
231 files['ext/a.js'] = 'require("./c.js");\nvar bar;'
232 files['lib/b.js'] = 'var foo;'
233 files['ext/c.js'] = 'var this_is_c;'
234
235 tmpdir.mkdir('lib').join('b.js').write(files['lib/b.js'])
236 ext_dir = tmpdir.mkdir('ext')
237 ext_dir.join('a.js').write(files['ext/a.js'])
238 ext_dir.join('c.js').write(files['ext/c.js'])
239
240 return files
241
242
243 def comparable_xml(xml):
Vasily Kuznetsov 2017/10/21 17:41:11 I suppose it doesn't matter for the purposes of th
tlucas 2017/10/22 11:19:12 I adjusted the docstring to clarify this limitatio
244 """Create a nonambiguous representation of a given XML tree."""
245 def get_leafs_string(tree):
246 """Recursively build a string representing all xml leaf-nodes."""
247 root_str = '{}|{}|{}'.format(tree.tag, tree.tail, tree.text).strip()
248 result = []
249
250 if len(tree) > 0:
251 for subtree in tree:
252 for leaf in get_leafs_string(subtree):
253 result.append('{}__{}'.format(root_str, leaf))
254 for k, v in tree.attrib.items():
255 result.append('{}__{}:{}'.format(root_str, k, v))
256 else:
257 result.append(root_str)
258 return result
259
260 # XML data itself shall not be sorted, hence we can safely sort
261 # our string representations in order to produce a valid unified diff.
262 return sorted(get_leafs_string(ElementTree.fromstring(xml)))
263
264
265 def comparable_json(json_string):
266 """Create a nonambiguous representation of a given JSON string."""
267 return json.dumps(
268 json.loads(json_string), sort_keys=True, indent=0
269 ).split('\n')
270
271
272 def assert_manifest_content(manifest, expected_path):
273 extension = os.path.basename(expected_path).split('.')[-1]
274
275 with open(expected_path, 'r') as fp:
276 if extension == 'xml':
277 generated = comparable_xml(manifest)
278 expected = comparable_xml(fp.read())
279 else:
280 generated = comparable_json(manifest)
281 expected = comparable_json(fp.read())
282
283 diff = list(difflib.unified_diff(generated, expected, n=0))
284 assert len(diff) == 0, '\n'.join(diff)
285
286
287 def assert_webpack_bundle(package, prefix, excluded=False):
288 libfoo = package.read(os.path.join(prefix, 'lib/foo.js'))
289 libfoomap = package.read(os.path.join(prefix, 'lib/foo.js.map'))
290
291 assert 'var bar;' in libfoo
292 assert 'webpack:///./ext/a.js' in libfoomap
293
294 assert 'var this_is_c;' in libfoo
295 assert 'webpack:///./ext/c.js' in libfoomap
296
297 assert ('var foo;' in libfoo) != excluded
298 assert ('webpack:///./lib/b.js' in libfoomap) != excluded
299
300
301 def assert_devenv_scripts(package, prefix, devenv):
302 manifest = json.loads(package.read(os.path.join(prefix, 'manifest.json')))
303 filenames = package.namelist()
304 scripts = [
305 'ext/common.js',
306 'ext/background.js',
307 ]
308
309 assert (os.path.join(prefix, 'qunit/index.html') in filenames) == devenv
310 assert (os.path.join(prefix, 'devenvPoller__.js') in filenames) == devenv
311 assert (os.path.join(prefix, 'devenvVersion__') in filenames) == devenv
312
313 if devenv:
314 quint_index = package.read(os.path.join(prefix, 'qunit/index.html'))
315 assert '../ext/common.js' in quint_index
316 assert '../ext/background.js' in quint_index
317
318 assert set(manifest['background']['scripts']) == set(
319 scripts + ['devenvPoller__.js']
320 )
321 else:
322 assert set(manifest['background']['scripts']) == set(scripts)
323
324
325 def assert_base_files(package, platform, prefix):
326 filenames = set(package.namelist())
327
328 if platform == 'edge':
329 assert 'AppxManifest.xml' in filenames
330 assert 'AppxBlockMap.xml' in filenames
331 assert '[Content_Types].xml' in filenames
332
333 assert package.read('Assets/logo_44.png') == '44'
334 assert package.read('Extension/icons/abp-44.png') == '44'
335
336 assert os.path.join(prefix, 'bar.json') in filenames
337 assert os.path.join(prefix, 'manifest.json') in filenames
338 assert os.path.join(prefix, 'lib/foo.js') in filenames
339 assert os.path.join(prefix, 'foo/logo_50.png') in filenames
340 assert os.path.join(prefix, 'icons/logo_150.png') in filenames
341
342
343 def assert_chrome_signature(filename, keyfile):
344 with open(filename, 'r') as fp:
345 content = fp.read()
346
347 _, _, l_pubkey, l_signature = unpack('<4sIII', content[:16])
348 signature = content[16 + l_pubkey: 16 + l_pubkey + l_signature]
349
350 digest = SHA.new()
351 with open(keyfile, 'r') as fp:
352 rsa_key = RSA.importKey(fp.read())
353
354 signer = PKCS1_v1_5.new(rsa_key)
355
356 digest.update(content[16 + l_pubkey + l_signature:])
357 assert signer.verify(digest, signature)
358
359
360 def assert_locale_upfix(package):
361 translations = [
362 json.loads(package.read('_locales/{}/messages.json'.format(lang)))
363 for lang in ALL_LANGUAGES
364 ]
365
366 manifest = package.read('manifest.json')
367
368 # Chrome Web Store requires descriptive translations to be present in
369 # every language.
370 for match in re.finditer(r'__MSG_(\S+)__', manifest):
371 name = match.group(1)
372
373 for other in translations[1:]:
374 assert translations[0][name]['message'] == other[name]['message']
375
376
377 @pytest.mark.usefixtures(
378 'all_lang_locales',
379 'locale_modules',
380 'icons',
381 'lib_files',
382 'chrome_metadata',
383 'gecko_webext_metadata',
384 'edge_metadata',
385 )
386 @pytest.mark.parametrize('platform,command', [
387 ('chrome', 'development_build'),
388 ('chrome', 'devenv'),
389 ('chrome', 'release_build'),
390 ('gecko', 'development_build'),
391 ('gecko', 'devenv'),
392 ('gecko', 'release_build'),
393 ('edge', 'development_build'),
394 ('edge', 'devenv'),
395 ('edge', 'release_build'),
396 ])
397 def test_build_webext(platform, command, keyfile, tmpdir, srcdir, capsys):
398 release = command == 'release_build'
399 devenv = command == 'devenv'
400
401 if platform == 'chrome' and release:
402 key = keyfile
403 else:
404 key = None
405
406 manifests = {
407 'gecko': [('', 'manifest', 'json')],
408 'chrome': [('', 'manifest', 'json')],
409 'edge': [('', 'AppxManifest', 'xml'),
410 ('Extension', 'manifest', 'json')],
411 }
412
413 filenames = {
414 'gecko': 'adblockplusfirefox-1.2.3{}.xpi',
415 'chrome': 'adblockpluschrome-1.2.3{{}}.{}'.format(
416 {True: 'crx', False: 'zip'}[release]
417 ),
418 'edge': 'adblockplusedge-1.2.3{}.appx',
419 }
420
421 if platform == 'edge':
422 prefix = 'Extension'
423 else:
424 prefix = ''
425
426 run_webext_build(platform, command, srcdir, keyfile=key)
427
428 # The makeIcons() in packagerChrome.py should warn about non-square
429 # icons via stderr.
430 out, err = capsys.readouterr()
431 assert 'icon should be square' in err
432
433 if devenv:
434 content_class = DirContent
435 out_file_path = os.path.join(str(srcdir), 'devenv.' + platform)
436 else:
437 content_class = ZipContent
438
439 if release:
440 add_version = ''
441 else:
442 add_version = '.1337'
443
444 out_file = filenames[platform].format(add_version)
445
446 out_file_path = os.path.abspath(os.path.join(
447 os.path.dirname(__file__), os.pardir, out_file))
448 assert os.path.exists(out_file_path)
449
450 if release and platform == 'chrome':
451 assert_chrome_signature(out_file_path, keyfile)
452
453 with content_class(out_file_path) as package:
454 assert_base_files(package, platform, prefix)
455 assert_all_locales_present(package, prefix)
456 assert_webpack_bundle(package, prefix, platform == 'gecko')
457
458 if platform == 'chrome':
459 assert_locale_upfix(package)
460
461 assert_devenv_scripts(package, prefix, devenv)
462
463 for folder, name, ext in manifests[platform]:
464 filename = '{{}}_{}_{}.{{}}'.format(platform, command)
465 expected = os.path.join(
466 os.path.dirname(__file__),
467 'expecteddata',
468 filename.format(name, ext),
469 )
470
471 assert_manifest_content(
472 package.read(os.path.join(folder, '{}.{}'.format(name, ext))),
473 expected,
474 )
OLDNEW

Powered by Google App Engine
This is Rietveld