4a26ff781c6718eda33e52dcb032adc692c09a12
[WebKit-https.git] / Tools / Scripts / webkitpy / w3c / test_importer.py
1 #!/usr/bin/env python
2
3 # Copyright (C) 2013 Adobe Systems Incorporated. All rights reserved.
4 # Copyright (C) 2015 Canon Inc. All rights reserved.
5 #
6 # Redistribution and use in source and binary forms, with or without
7 # modification, are permitted provided that the following conditions
8 # are met:
9 #
10 # 1. Redistributions of source code must retain the above
11 #    copyright notice, this list of conditions and the following
12 #    disclaimer.
13 # 2. Redistributions in binary form must reproduce the above
14 #    copyright notice, this list of conditions and the following
15 #    disclaimer in the documentation and/or other materials
16 #    provided with the distribution.
17 #
18 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER "AS IS" AND ANY
19 # EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
20 # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
21 # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE
22 # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
23 # OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
24 # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
25 # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26 # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR
27 # TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
28 # THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
29 # SUCH DAMAGE.
30
31 """
32  This script imports W3C tests into WebKit, either from a local folder or by cloning W3C CSS and WPT repositories.
33
34  This script will import the tests into WebKit following these rules:
35
36     - All tests are by default imported into LayoutTests/imported/w3c
37
38     - Tests will be imported into a directory tree that
39       mirrors the CSS and WPT repositories. For example, <csswg_repo_root>/css2.1 should be brought in
40       as LayoutTests/imported/w3c/csswg-tests/css2.1, maintaining the entire directory structure under that
41
42     - By default, only reftests and jstest are imported. This can be overridden with a -a or --all
43       argument
44
45     - Also by default, if test files by the same name already exist in the destination directory,
46       they are overwritten with the idea that running this script would refresh files periodically.
47       This can also be overridden by a -n or --no-overwrite flag
48
49     - If no import_directory is provided, the script will download the tests from the W3C github repositories.
50       The selection of tests and folders to import will be based on the following files:
51          1. LayoutTests/imported/w3c/resources/TestRepositories lists the repositories to clone, the corresponding revision to checkout and the infrastructure folders that need to be imported/skipped.
52          2. LayoutTests/imported/w3c/resources/ImportExpectations list the test suites or tests to NOT import.
53
54     - All files are converted to work in WebKit:
55          1. Paths to testharness.js files are modified to point to Webkit's copy of them in
56             LayoutTests/resources, using the correct relative path from the new location.
57             This is applied to CSS tests but not to WPT tests.
58          2. All CSS properties requiring the -webkit-vendor prefix are prefixed - this current
59             list of what needs prefixes is read from Source/WebCore/CSS/CSSProperties.in
60          3. Each reftest has its own copy of its reference file following the naming conventions
61             new-run-webkit-tests expects
62          4. If a reference files lives outside the directory of the test that uses it, it is checked
63             for paths to support files as it will be imported into a different relative position to the
64             test file (in the same directory)
65
66      - Upon completion, script outputs the total number tests imported, broken down by test type
67
68      - Also upon completion, each directory where files are imported will have w3c-import.log written
69        with a timestamp, the list of CSS properties used that require prefixes, the list of imported files,
70        and guidance for future test modification and maintenance.
71
72      - On subsequent imports, this file is read to determine if files have been removed in the newer changesets.
73        The script removes these files accordingly.
74 """
75
76 import argparse
77 import datetime
78 import logging
79 import mimetypes
80 import os
81 import sys
82
83 from webkitpy.common.host import Host
84 from webkitpy.common.system.filesystem import FileSystem
85 from webkitpy.common.webkit_finder import WebKitFinder
86 from webkitpy.common.system.executive import ScriptError
87 from webkitpy.w3c.test_parser import TestParser
88 from webkitpy.w3c.test_converter import convert_for_webkit
89 from webkitpy.w3c.test_downloader import TestDownloader
90
91 CHANGESET_NOT_AVAILABLE = 'Not Available'
92
93 _log = logging.getLogger(__name__)
94
95
96 def main(_argv, _stdout, _stderr):
97     options, args = parse_args(_argv)
98     import_dir = args[0] if args else None
99     filesystem = FileSystem()
100     if import_dir and not filesystem.exists(import_dir):
101         sys.exit('Source directory %s not found!' % import_dir)
102
103     configure_logging()
104
105     test_importer = TestImporter(Host(), import_dir, options)
106     test_importer.do_import()
107
108
109 def configure_logging():
110     class LogHandler(logging.StreamHandler):
111
112         def format(self, record):
113             if record.levelno > logging.INFO:
114                 return "%s: %s" % (record.levelname, record.getMessage())
115             return record.getMessage()
116
117     logger = logging.getLogger()
118     logger.setLevel(logging.INFO)
119     handler = LogHandler()
120     handler.setLevel(logging.INFO)
121     logger.addHandler(handler)
122     return handler
123
124
125 def parse_args(args):
126     parser = argparse.ArgumentParser(prog='import-w3c-tests [w3c_test_source_directory]')
127
128     parser.add_argument('-n', '--no-overwrite', dest='overwrite', action='store_false', default=True,
129         help='Flag to prevent duplicate test files from overwriting existing tests. By default, they will be overwritten')
130     parser.add_argument('-l', '--no-links-conversion', dest='convert_test_harness_links', action='store_false', default=True,
131        help='Do not change links (testharness js or css e.g.). This option only applies when providing a source directory, in which case by default, links are converted to point to WebKit testharness files. When tests are downloaded from W3C repository, links are converted for CSS tests and remain unchanged for WPT tests')
132
133     parser.add_argument('-a', '--all', action='store_true', default=False,
134         help='Import all tests including reftests, JS tests, and manual/pixel tests. By default, only reftests and JS tests are imported')
135     fs = FileSystem()
136     parser.add_argument('-d', '--dest-dir', dest='destination', default=fs.join('imported', 'w3c'),
137         help='Import into a specified directory relative to the LayoutTests root. By default, imports into imported/w3c')
138
139     list_of_repositories = ' or '.join([test_repository['name'] for test_repository in TestDownloader.load_test_repositories()])
140     parser.add_argument('-t', '--test-path', action='append', dest='test_paths', default=[],
141          help='Import only tests in the supplied subdirectory of the source directory. Can be supplied multiple times to give multiple paths. For tests directly cloned from W3C repositories, use ' + list_of_repositories + ' prefixes to filter specific tests')
142
143     parser.add_argument('-v', '--verbose', action='store_true', default=False,
144          help='Print maximal log')
145     parser.add_argument('--no-fetch', action='store_false', dest='fetch', default=True,
146          help='Do not fetch the repositories. By default, repositories are fetched if a source directory is not provided')
147     parser.add_argument('--import-all', action='store_true', default=False,
148          help='Ignore the ImportExpectations file. All tests will be imported. This option only applies when tests are downloaded from W3C repository')
149
150     options, args = parser.parse_known_args(args)
151     if len(args) > 1:
152         parser.error('Incorrect number of arguments')
153     return options, args
154
155
156 class TestImporter(object):
157
158     def __init__(self, host, source_directory, options):
159         self.host = host
160         self.source_directory = source_directory
161         self.options = options
162
163         self.filesystem = self.host.filesystem
164
165         webkit_finder = WebKitFinder(self.filesystem)
166         self._webkit_root = webkit_finder.webkit_base()
167
168         self.destination_directory = webkit_finder.path_from_webkit_base("LayoutTests", options.destination)
169         self.tests_w3c_relative_path = self.filesystem.join('imported', 'w3c')
170         self.layout_tests_w3c_path = webkit_finder.path_from_webkit_base('LayoutTests', self.tests_w3c_relative_path)
171         self.tests_download_path = webkit_finder.path_from_webkit_base('WebKitBuild', 'w3c-tests')
172
173         self._test_downloader = None
174
175         self._potential_test_resource_files = []
176
177         self.import_list = []
178         self._importing_downloaded_tests = source_directory is None
179
180     def do_import(self):
181         if not self.source_directory:
182             _log.info('Downloading W3C test repositories')
183             self.source_directory = self.filesystem.join(self.tests_download_path, 'to-be-imported')
184             self.filesystem.maybe_make_directory(self.tests_download_path)
185             self.filesystem.maybe_make_directory(self.source_directory)
186             self.test_downloader().download_tests(self.source_directory, self.options.test_paths)
187
188         if not self.options.test_paths or self._importing_downloaded_tests:
189             self.find_importable_tests(self.source_directory)
190         else:
191             for test_path in self.options.test_paths:
192                 self.find_importable_tests(self.filesystem.join(self.source_directory, test_path))
193
194         self.import_tests()
195
196         if self._importing_downloaded_tests:
197             self.generate_git_submodules_description_for_all_repositories()
198
199     def generate_git_submodules_description_for_all_repositories(self):
200         for test_repository in self._test_downloader.test_repositories:
201             if 'generate_git_submodules_description' in test_repository['import_options']:
202                 self.filesystem.maybe_make_directory(self.filesystem.join(self.destination_directory, 'resources'))
203                 self._test_downloader.generate_git_submodules_description(test_repository, self.filesystem.join(self.destination_directory, 'resources', test_repository['name'] + '-modules.json'))
204             # FIXME: Generate WPT .gitignore and  main __init__.py
205
206     def test_downloader(self):
207         if not self._test_downloader:
208             download_options = TestDownloader.default_options()
209             download_options.fetch = self.options.fetch
210             download_options.verbose = self.options.verbose
211             download_options.import_all = self.options.import_all
212             self._test_downloader = TestDownloader(self.tests_download_path, self.host, download_options)
213         return self._test_downloader
214
215     def should_skip_file(self, filename):
216         # For some reason the w3c repo contains random perl scripts we don't care about.
217         if filename.endswith('.pl'):
218             return True
219         if filename.startswith('.'):
220             return not filename == '.htaccess'
221         return False
222
223     def find_importable_tests(self, directory):
224         def should_keep_subdir(filesystem, path):
225             if self._importing_downloaded_tests:
226                 return True
227             subdir = path[len(directory):]
228             DIRS_TO_SKIP = ('work-in-progress', 'tools', 'support')
229             should_skip = filesystem.basename(subdir).startswith('.') or (subdir in DIRS_TO_SKIP)
230             return not should_skip
231
232         directories = self.filesystem.dirs_under(directory, should_keep_subdir)
233         for root in directories:
234             _log.info('Scanning ' + root + '...')
235             total_tests = 0
236             reftests = 0
237             jstests = 0
238
239             copy_list = []
240
241             for filename in self.filesystem.listdir(root):
242                 if self.filesystem.isdir(self.filesystem.join(root, filename)):
243                     continue
244                 # FIXME: This block should really be a separate function, but the early-continues make that difficult.
245
246                 if self.should_skip_file(filename):
247                     continue
248
249                 fullpath = self.filesystem.join(root, filename)
250
251                 mimetype = mimetypes.guess_type(fullpath)
252                 if not 'html' in str(mimetype[0]) and not 'application/xhtml+xml' in str(mimetype[0]) and not 'application/xml' in str(mimetype[0]):
253                     copy_list.append({'src': fullpath, 'dest': filename})
254                     continue
255
256                 test_parser = TestParser(vars(self.options), filename=fullpath, host=self.host)
257                 test_info = test_parser.analyze_test()
258                 if test_info is None:
259                     # This is probably a resource file.
260                     if self.filesystem.basename(self.filesystem.dirname(fullpath)) != "resources":
261                         self._potential_test_resource_files.append({'src': fullpath, 'dest': filename})
262                     copy_list.append({'src': fullpath, 'dest': filename})
263                     continue
264
265                 if 'manualtest' in test_info.keys():
266                     continue
267
268                 if 'referencefile' in test_info.keys():
269                     # Skip it since, the corresponding reference test should have a link to this file
270                     continue
271
272                 if 'reference' in test_info.keys():
273                     reftests += 1
274                     total_tests += 1
275                     test_basename = self.filesystem.basename(test_info['test'])
276
277                     # Add the ref file, following WebKit style.
278                     # FIXME: Ideally we'd support reading the metadata
279                     # directly rather than relying  on a naming convention.
280                     # Using a naming convention creates duplicate copies of the
281                     # reference files.
282                     ref_file = self.filesystem.splitext(test_basename)[0] + '-expected'
283                     ref_file += self.filesystem.splitext(test_info['reference'])[1]
284
285                     copy_list.append({'src': test_info['reference'], 'dest': ref_file, 'reference_support_info': test_info['reference_support_info']})
286                     copy_list.append({'src': test_info['test'], 'dest': filename})
287
288                 elif 'jstest' in test_info.keys():
289                     jstests += 1
290                     total_tests += 1
291                     copy_list.append({'src': fullpath, 'dest': filename})
292                 else:
293                     total_tests += 1
294                     copy_list.append({'src': fullpath, 'dest': filename})
295
296             if copy_list:
297                 # Only add this directory to the list if there's something to import
298                 self.import_list.append({'dirname': root, 'copy_list': copy_list,
299                     'reftests': reftests, 'jstests': jstests, 'total_tests': total_tests})
300
301     def should_convert_test_harness_links(self, test):
302         if self._importing_downloaded_tests:
303             for test_repository in self.test_downloader().test_repositories:
304                 if test.startswith(test_repository['name']):
305                     return 'convert_test_harness_links' in test_repository['import_options']
306             return True
307         return self.options.convert_test_harness_links
308
309     def import_tests(self):
310         total_imported_tests = 0
311         total_imported_reftests = 0
312         total_imported_jstests = 0
313         total_prefixed_properties = {}
314         total_prefixed_property_values = {}
315
316         failed_conversion_files = []
317
318         for dir_to_copy in self.import_list:
319             total_imported_tests += dir_to_copy['total_tests']
320             total_imported_reftests += dir_to_copy['reftests']
321             total_imported_jstests += dir_to_copy['jstests']
322
323             prefixed_properties = []
324             prefixed_property_values = []
325
326             if not dir_to_copy['copy_list']:
327                 continue
328
329             orig_path = dir_to_copy['dirname']
330
331             subpath = self.filesystem.relpath(orig_path, self.source_directory)
332             new_path = self.filesystem.join(self.destination_directory, subpath)
333
334             if not(self.filesystem.exists(new_path)):
335                 self.filesystem.maybe_make_directory(new_path)
336
337             copied_files = []
338
339             for file_to_copy in dir_to_copy['copy_list']:
340                 # FIXME: Split this block into a separate function.
341                 orig_filepath = self.filesystem.normpath(file_to_copy['src'])
342
343                 if self.filesystem.isdir(orig_filepath):
344                     # FIXME: Figure out what is triggering this and what to do about it.
345                     _log.error('%s refers to a directory' % orig_filepath)
346                     continue
347
348                 if not(self.filesystem.exists(orig_filepath)):
349                     _log.warning('%s not found. Possible error in the test.', orig_filepath)
350                     continue
351
352                 new_filepath = self.filesystem.join(new_path, file_to_copy['dest'])
353                 if 'reference_support_info' in file_to_copy.keys() and file_to_copy['reference_support_info'] != {}:
354                     reference_support_info = file_to_copy['reference_support_info']
355                 else:
356                     reference_support_info = None
357
358                 if not(self.filesystem.exists(self.filesystem.dirname(new_filepath))):
359                     self.filesystem.maybe_make_directory(self.filesystem.dirname(new_filepath))
360
361                 if not self.options.overwrite and self.filesystem.exists(new_filepath):
362                     _log.info('Skipping import of existing file ' + new_filepath)
363                 else:
364                     # FIXME: Maybe doing a file diff is in order here for existing files?
365                     # In other words, there's no sense in overwriting identical files, but
366                     # there's no harm in copying the identical thing.
367                     _log.info('Importing: %s', orig_filepath)
368                     _log.info('       As: %s', new_filepath)
369
370                 # Only html, xml, or css should be converted
371                 # FIXME: Eventually, so should js when support is added for this type of conversion
372                 mimetype = mimetypes.guess_type(orig_filepath)
373                 if 'html' in str(mimetype[0]) or 'xml' in str(mimetype[0])  or 'css' in str(mimetype[0]):
374                     try:
375                         converted_file = convert_for_webkit(new_path, filename=orig_filepath, reference_support_info=reference_support_info, host=self.host, convert_test_harness_links=self.should_convert_test_harness_links(subpath))
376                     except:
377                         _log.warn('Failed converting %s', orig_filepath)
378                         failed_conversion_files.append(orig_filepath)
379                         converted_file = None
380
381                     if not converted_file:
382                         self.filesystem.copyfile(orig_filepath, new_filepath)  # The file was unmodified.
383                     else:
384                         for prefixed_property in converted_file[0]:
385                             total_prefixed_properties.setdefault(prefixed_property, 0)
386                             total_prefixed_properties[prefixed_property] += 1
387
388                         prefixed_properties.extend(set(converted_file[0]) - set(prefixed_properties))
389
390                         for prefixed_value in converted_file[1]:
391                             total_prefixed_property_values.setdefault(prefixed_value, 0)
392                             total_prefixed_property_values[prefixed_value] += 1
393
394                         prefixed_property_values.extend(set(converted_file[1]) - set(prefixed_property_values))
395
396                         self.filesystem.write_binary_file(new_filepath, converted_file[2])
397                 elif orig_filepath.endswith('__init__.py') and not self.filesystem.getsize(orig_filepath):
398                     # Some bots dislike empty __init__.py.
399                     self.filesystem.write_text_file(new_filepath, '# This file is required for Python to search this directory for modules.')
400                 else:
401                     self.filesystem.copyfile(orig_filepath, new_filepath)
402
403                 copied_files.append(new_filepath.replace(self._webkit_root, ''))
404
405             self.remove_deleted_files(new_path, copied_files)
406             self.write_import_log(new_path, copied_files, prefixed_properties, prefixed_property_values)
407
408         _log.info('Import complete')
409
410         _log.info('IMPORTED %d TOTAL TESTS', total_imported_tests)
411         _log.info('Imported %d reftests', total_imported_reftests)
412         _log.info('Imported %d JS tests', total_imported_jstests)
413         _log.info('Imported %d pixel/manual tests', total_imported_tests - total_imported_jstests - total_imported_reftests)
414         if len(failed_conversion_files):
415             _log.warn('Failed converting %d files (files copied without being converted)', len(failed_conversion_files))
416         _log.info('')
417         _log.info('Properties needing prefixes (by count):')
418
419         for prefixed_property in sorted(total_prefixed_properties, key=lambda p: total_prefixed_properties[p]):
420             _log.info('  %s: %s', prefixed_property, total_prefixed_properties[prefixed_property])
421         _log.info('')
422         _log.info('Property values needing prefixes (by count):')
423
424         for prefixed_value in sorted(total_prefixed_property_values, key=lambda p: total_prefixed_property_values[p]):
425             _log.info('  %s: %s', prefixed_value, total_prefixed_property_values[prefixed_value])
426
427         if self._potential_test_resource_files:
428             _log.info('The following files may be resource files and should be marked as skipped in the TestExpectations:')
429             for filename in sorted([test['src'] for test in self._potential_test_resource_files]):
430                 _log.info(filename.replace(self.source_directory, self.tests_w3c_relative_path) + ' [ Skip ]')
431
432     def remove_deleted_files(self, import_directory, new_file_list):
433         """ Reads an import log in |import_directory|, compares it to the |new_file_list|, and removes files not in the new list."""
434
435         previous_file_list = []
436
437         import_log_file = self.filesystem.join(import_directory, 'w3c-import.log')
438         if not self.filesystem.exists(import_log_file):
439             return
440
441         contents = self.filesystem.read_text_file(import_log_file).split('\n')
442
443         if 'List of files\n' in contents:
444             list_index = contents.index('List of files:\n') + 1
445             previous_file_list = [filename.strip() for filename in contents[list_index:]]
446
447         deleted_files = set(previous_file_list) - set(new_file_list)
448         for deleted_file in deleted_files:
449             _log.info('Deleting file removed from the W3C repo: %s', deleted_file)
450             deleted_file = self.filesystem.join(self._webkit_root, deleted_file)
451             self.filesystem.remove(deleted_file)
452
453     def write_import_log(self, import_directory, file_list, prop_list, property_values_list):
454         """ Writes a w3c-import.log file in each directory with imported files. """
455
456         import_log = []
457         import_log.append('The tests in this directory were imported from the W3C repository.\n')
458         import_log.append('Do NOT modify these tests directly in Webkit.\n')
459         import_log.append('Instead, create a pull request on the W3C CSS or WPT github:\n')
460         import_log.append('\thttps://github.com/w3c/csswg-test\n')
461         import_log.append('\thttps://github.com/w3c/web-platform-tests\n\n')
462         import_log.append('Then run the Tools/Scripts/import-w3c-tests in Webkit to reimport\n\n')
463         import_log.append('Do NOT modify or remove this file\n\n')
464         import_log.append('------------------------------------------------------------------------\n')
465         import_log.append('Properties requiring vendor prefixes:\n')
466         if prop_list:
467             for prop in prop_list:
468                 import_log.append(prop + '\n')
469         else:
470             import_log.append('None\n')
471         import_log.append('Property values requiring vendor prefixes:\n')
472         if property_values_list:
473             for value in property_values_list:
474                 import_log.append(value + '\n')
475         else:
476             import_log.append('None\n')
477         import_log.append('------------------------------------------------------------------------\n')
478         import_log.append('List of files:\n')
479         for item in sorted(file_list):
480             import_log.append(item + '\n')
481
482         self.filesystem.write_text_file(self.filesystem.join(import_directory, 'w3c-import.log'), ''.join(import_log))