Sync up w3c import script with changes in Blink
[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 #
5 # Redistribution and use in source and binary forms, with or without
6 # modification, are permitted provided that the following conditions
7 # are met:
8 #
9 # 1. Redistributions of source code must retain the above
10 #    copyright notice, this list of conditions and the following
11 #    disclaimer.
12 # 2. Redistributions in binary form must reproduce the above
13 #    copyright notice, this list of conditions and the following
14 #    disclaimer in the documentation and/or other materials
15 #    provided with the distribution.
16 #
17 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER "AS IS" AND ANY
18 # EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
19 # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
20 # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE
21 # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
22 # OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
23 # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
24 # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
25 # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR
26 # TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
27 # THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
28 # SUCH DAMAGE.
29
30 """
31  This script imports a directory of W3C CSS tests into WebKit.
32
33  You must have checked out the W3C repository to your local drive.
34
35  This script will import the tests into WebKit following these rules:
36
37     - Only tests that are approved or officially submitted awaiting review are imported
38
39     - All tests are imported into LayoutTests/csswg
40
41     - If the tests are approved, they'll be imported into a directory tree that
42       mirrors the CSS Mercurial repo. For example, <csswg_repo_root>/approved/css2.1 is brought in
43       as LayoutTests/csswg/approved/css2.1, maintaining the entire directory structure under that
44
45     - If the tests are submitted, they'll be brought in as LayoutTests/csswg/submitted and will also
46       maintain their directory structure under that. For example, everything under
47       <csswg_repo_root>/contributors/adobe/submitted is brought into submitted, mirroring its
48       directory structure in the csswg repo
49
50     - If the import directory specified is just a contributor folder, only the submitted folder
51       for that contributor is brought in. For example, to import all of Mozilla's tests, either
52       <csswg_repo_root>/contributors/mozilla or <csswg_repo_root>/contributors/mozilla/submitted
53       will work and are equivalent
54
55     - For the time being, this script won't work if you try to import the full set of submitted
56       tests under contributors/*/submitted. Since these are awaiting review, this is just a small
57       control mechanism to enforce carefully selecting what non-approved tests are imported.
58       It can obviously and easily be changed.
59
60     - By default, only reftests and jstest are imported. This can be overridden with a -a or --all
61       argument
62
63     - Also by default, if test files by the same name already exist in the destination directory,
64       they are overwritten with the idea that running this script would refresh files periodically.
65       This can also be overridden by a -n or --no-overwrite flag
66
67     - All files are converted to work in WebKit:
68          1. Paths to testharness.js files are modified point to Webkit's copy of them in
69             LayoutTests/resources, using the correct relative path from the new location
70          2. All CSS properties requiring the -webkit-vendor prefix are prefixed - this current
71             list of what needs prefixes is read from Source/WebCore/CSS/CSSProperties.in
72          3. Each reftest has its own copy of its reference file following the naming conventions
73             new-run-webkit-tests expects
74          4. If a reference files lives outside the directory of the test that uses it, it is checked
75             for paths to support files as it will be imported into a different relative position to the
76             test file (in the same directory)
77
78      - Upon completion, script outputs the total number tests imported, broken down by test type
79
80      - Also upon completion, each directory where files are imported will have w3c-import.log written
81        with a timestamp, the W3C Mercurial changeset if available, the list of CSS properties used that
82        require prefixes, the list of imported files, and guidance for future test modification and
83        maintenance.
84
85      - On subsequent imports, this file is read to determine if files have been removed in the newer changesets.
86        The script removes these files accordingly.
87 """
88
89 # FIXME: Change this file to use the Host abstractions rather that os, sys, shutils, etc.
90
91 import datetime
92 import logging
93 import mimetypes
94 import optparse
95 import os
96 import shutil
97 import sys
98
99 from webkitpy.common.host import Host
100 from webkitpy.common.webkit_finder import WebKitFinder
101 from webkitpy.common.system.executive import ScriptError
102 from webkitpy.w3c.test_parser import TestParser
103 from webkitpy.w3c.test_converter import W3CTestConverter
104
105
106 TEST_STATUS_UNKNOWN = 'unknown'
107 TEST_STATUS_APPROVED = 'approved'
108 TEST_STATUS_SUBMITTED = 'submitted'
109
110 CHANGESET_NOT_AVAILABLE = 'Not Available'
111
112
113 _log = logging.getLogger(__name__)
114
115
116 def main(_argv, _stdout, _stderr):
117     options, args = parse_args()
118     import_dir = args[0]
119     if len(args) == 1:
120         repo_dir = os.path.dirname(import_dir)
121     else:
122         repo_dir = args[1]
123
124     if not os.path.exists(import_dir):
125         sys.exit('Source directory %s not found!' % import_dir)
126
127     if not os.path.exists(repo_dir):
128         sys.exit('Repository directory %s not found!' % repo_dir)
129     if not repo_dir in import_dir:
130         sys.exit('Repository directory %s must be a parent of %s' % (repo_dir, import_dir))
131
132     configure_logging()
133
134     test_importer = TestImporter(Host(), import_dir, repo_dir, options)
135     test_importer.do_import()
136
137
138 def configure_logging():
139     class LogHandler(logging.StreamHandler):
140
141         def format(self, record):
142             if record.levelno > logging.INFO:
143                 return "%s: %s" % (record.levelname, record.getMessage())
144             return record.getMessage()
145
146     logger = logging.getLogger()
147     logger.setLevel(logging.INFO)
148     handler = LogHandler()
149     handler.setLevel(logging.INFO)
150     logger.addHandler(handler)
151     return handler
152
153
154 def parse_args():
155     parser = optparse.OptionParser(usage='usage: %prog [options] w3c_test_directory [repo_directory]')
156     parser.add_option('-n', '--no-overwrite', dest='overwrite', action='store_false', default=True,
157         help='Flag to prevent duplicate test files from overwriting existing tests. By default, they will be overwritten')
158     parser.add_option('-a', '--all', action='store_true', default=False,
159         help='Import all tests including reftests, JS tests, and manual/pixel tests. By default, only reftests and JS tests are imported')
160
161     options, args = parser.parse_args()
162     if len(args) not in (1, 2):
163         parser.error('Incorrect number of arguments')
164     return options, args
165
166
167 class TestImporter(object):
168
169     def __init__(self, host, source_directory, repo_dir, options):
170         self.host = host
171         self.source_directory = source_directory
172         self.options = options
173
174         self.filesystem = self.host.filesystem
175
176         webkit_finder = WebKitFinder(self.filesystem)
177         self._webkit_root = webkit_finder.webkit_base()
178         self.repo_dir = repo_dir
179         subdirs = os.path.dirname(os.path.relpath(source_directory, repo_dir))
180
181         self.destination_directory = webkit_finder.path_from_webkit_base("LayoutTests", 'w3c', *subdirs)
182
183         self.changeset = CHANGESET_NOT_AVAILABLE
184         self.test_status = TEST_STATUS_UNKNOWN
185
186         self.import_list = []
187
188     def do_import(self):
189         self.find_importable_tests(self.source_directory)
190         self.load_changeset()
191         self.import_tests()
192
193     def load_changeset(self):
194         """Returns the current changeset from mercurial or "Not Available"."""
195         try:
196             self.changeset = self.host.executive.run_command(['hg', 'tip']).split('changeset:')[1]
197         except (OSError, ScriptError):
198             self.changeset = CHANGESET_NOT_AVAILABLE
199
200     def find_importable_tests(self, directory):
201         # FIXME: use filesystem
202         for root, dirs, files in os.walk(directory):
203             _log.info('Scanning ' + root + '...')
204             total_tests = 0
205             reftests = 0
206             jstests = 0
207
208             # "archive" and "data" dirs are internal csswg things that live in every approved directory.
209             # FIXME: skip 'incoming' tests for now, but we should rework the 'test_status' concept and
210             # support reading them as well.
211             DIRS_TO_SKIP = ('.git', '.hg', 'data', 'archive', 'incoming')
212             for d in DIRS_TO_SKIP:
213                 if d in dirs:
214                     dirs.remove(d)
215
216             copy_list = []
217
218             for filename in files:
219                 # FIXME: This block should really be a separate function, but the early-continues make that difficult.
220
221                 if filename.startswith('.') or filename.endswith('.pl'):
222                     continue  # For some reason the w3c repo contains random perl scripts we don't care about.
223
224                 fullpath = os.path.join(root, filename)
225
226                 mimetype = mimetypes.guess_type(fullpath)
227                 if not 'html' in str(mimetype[0]) and not 'xml' in str(mimetype[0]):
228                     copy_list.append({'src': fullpath, 'dest': filename})
229                     continue
230
231                 test_parser = TestParser(vars(self.options), filename=fullpath)
232                 test_info = test_parser.analyze_test()
233                 if test_info is None:
234                     continue
235
236                 if 'reference' in test_info.keys():
237                     reftests += 1
238                     total_tests += 1
239                     test_basename = os.path.basename(test_info['test'])
240
241                     # Add the ref file, following WebKit style.
242                     # FIXME: Ideally we'd support reading the metadata
243                     # directly rather than relying  on a naming convention.
244                     # Using a naming convention creates duplicate copies of the
245                     # reference files.
246                     ref_file = os.path.splitext(test_basename)[0] + '-expected'
247                     ref_file += os.path.splitext(test_basename)[1]
248
249                     copy_list.append({'src': test_info['reference'], 'dest': ref_file})
250                     copy_list.append({'src': test_info['test'], 'dest': filename})
251
252                     # Update any support files that need to move as well to remain relative to the -expected file.
253                     if 'refsupport' in test_info.keys():
254                         for support_file in test_info['refsupport']:
255                             source_file = os.path.join(os.path.dirname(test_info['reference']), support_file)
256                             source_file = os.path.normpath(source_file)
257
258                             # Keep the dest as it was
259                             to_copy = {'src': source_file, 'dest': support_file}
260
261                             # Only add it once
262                             if not(to_copy in copy_list):
263                                 copy_list.append(to_copy)
264                 elif 'jstest' in test_info.keys():
265                     jstests += 1
266                     total_tests += 1
267                     copy_list.append({'src': fullpath, 'dest': filename})
268                 else:
269                     total_tests += 1
270                     copy_list.append({'src': fullpath, 'dest': filename})
271
272             if not total_tests:
273                 # We can skip the support directory if no tests were found.
274                 if 'support' in dirs:
275                     dirs.remove('support')
276
277             if copy_list:
278                 # Only add this directory to the list if there's something to import
279                 self.import_list.append({'dirname': root, 'copy_list': copy_list,
280                     'reftests': reftests, 'jstests': jstests, 'total_tests': total_tests})
281
282     def import_tests(self):
283         converter = W3CTestConverter()
284         total_imported_tests = 0
285         total_imported_reftests = 0
286         total_imported_jstests = 0
287         total_prefixed_properties = {}
288
289         for dir_to_copy in self.import_list:
290             total_imported_tests += dir_to_copy['total_tests']
291             total_imported_reftests += dir_to_copy['reftests']
292             total_imported_jstests += dir_to_copy['jstests']
293
294             prefixed_properties = []
295
296             if not dir_to_copy['copy_list']:
297                 continue
298
299             orig_path = dir_to_copy['dirname']
300
301             subpath = os.path.relpath(orig_path, self.repo_dir)
302             new_path = os.path.join(self.destination_directory, subpath)
303
304             if not(os.path.exists(new_path)):
305                 os.makedirs(new_path)
306
307             copied_files = []
308
309             for file_to_copy in dir_to_copy['copy_list']:
310                 # FIXME: Split this block into a separate function.
311                 orig_filepath = os.path.normpath(file_to_copy['src'])
312
313                 if os.path.isdir(orig_filepath):
314                     # FIXME: Figure out what is triggering this and what to do about it.
315                     _log.error('%s refers to a directory' % orig_filepath)
316                     continue
317
318                 if not(os.path.exists(orig_filepath)):
319                     _log.warning('%s not found. Possible error in the test.', orig_filepath)
320                     continue
321
322                 new_filepath = os.path.join(new_path, file_to_copy['dest'])
323
324                 if not(os.path.exists(os.path.dirname(new_filepath))):
325                     os.makedirs(os.path.dirname(new_filepath))
326
327                 if not self.options.overwrite and os.path.exists(new_filepath):
328                     _log.info('Skipping import of existing file ' + new_filepath)
329                 else:
330                     # FIXME: Maybe doing a file diff is in order here for existing files?
331                     # In other words, there's no sense in overwriting identical files, but
332                     # there's no harm in copying the identical thing.
333                     _log.info('Importing: %s', orig_filepath)
334                     _log.info('       As: %s', new_filepath)
335
336                 # Only html, xml, or css should be converted
337                 # FIXME: Eventually, so should js when support is added for this type of conversion
338                 mimetype = mimetypes.guess_type(orig_filepath)
339                 if 'html' in str(mimetype[0]) or 'xml' in str(mimetype[0])  or 'css' in str(mimetype[0]):
340                     converted_file = converter.convert_for_webkit(new_path, filename=orig_filepath)
341
342                     if not converted_file:
343                         shutil.copyfile(orig_filepath, new_filepath)  # The file was unmodified.
344                     else:
345                         for prefixed_property in converted_file[0]:
346                             total_prefixed_properties.setdefault(prefixed_property, 0)
347                             total_prefixed_properties[prefixed_property] += 1
348
349                         prefixed_properties.extend(set(converted_file[0]) - set(prefixed_properties))
350                         outfile = open(new_filepath, 'wb')
351                         outfile.write(converted_file[1])
352                         outfile.close()
353                 else:
354                     shutil.copyfile(orig_filepath, new_filepath)
355
356                 copied_files.append(new_filepath.replace(self._webkit_root, ''))
357
358             self.remove_deleted_files(new_path, copied_files)
359             self.write_import_log(new_path, copied_files, prefixed_properties)
360
361         _log.info('Import complete')
362
363         _log.info('IMPORTED %d TOTAL TESTS', total_imported_tests)
364         _log.info('Imported %d reftests', total_imported_reftests)
365         _log.info('Imported %d JS tests', total_imported_jstests)
366         _log.info('Imported %d pixel/manual tests', total_imported_tests - total_imported_jstests - total_imported_reftests)
367         _log.info('')
368         _log.info('Properties needing prefixes (by count):')
369         for prefixed_property in sorted(total_prefixed_properties, key=lambda p: total_prefixed_properties[p]):
370             _log.info('  %s: %s', prefixed_property, total_prefixed_properties[prefixed_property])
371
372     def setup_destination_directory(self):
373         """ Creates a destination directory that mirrors that of the source approved or submitted directory """
374
375         self.update_test_status()
376
377         start = self.source_directory.find(self.test_status)
378         new_subpath = self.source_directory[len(self.repo_dir):]
379
380         destination_directory = os.path.join(self.destination_directory, new_subpath)
381
382         if not os.path.exists(destination_directory):
383             os.makedirs(destination_directory)
384
385         _log.info('Tests will be imported into: %s', destination_directory)
386
387     def update_test_status(self):
388         """ Sets the test status to either 'approved' or 'submitted' """
389
390         status = TEST_STATUS_UNKNOWN
391
392         if 'approved' in self.source_directory.split(os.path.sep):
393             status = TEST_STATUS_APPROVED
394         elif 'submitted' in self.source_directory.split(os.path.sep):
395             status = TEST_STATUS_SUBMITTED
396
397         self.test_status = status
398
399     def remove_deleted_files(self, import_directory, new_file_list):
400         """ Reads an import log in |import_directory|, compares it to the |new_file_list|, and removes files not in the new list."""
401
402         previous_file_list = []
403
404         import_log_file = os.path.join(import_directory, 'w3c-import.log')
405         if not os.path.exists(import_log_file):
406             return
407
408         import_log = open(import_log_file, 'r')
409         contents = import_log.readlines()
410
411         if 'List of files\n' in contents:
412             list_index = contents.index('List of files:\n') + 1
413             previous_file_list = [filename.strip() for filename in contents[list_index:]]
414
415         deleted_files = set(previous_file_list) - set(new_file_list)
416         for deleted_file in deleted_files:
417             _log.info('Deleting file removed from the W3C repo: %s', deleted_file)
418             deleted_file = os.path.join(self._webkit_root, deleted_file)
419             os.remove(deleted_file)
420
421         import_log.close()
422
423     def write_import_log(self, import_directory, file_list, prop_list):
424         """ Writes a w3c-import.log file in each directory with imported files. """
425
426         now = datetime.datetime.now()
427
428         import_log = open(os.path.join(import_directory, 'w3c-import.log'), 'w')
429         import_log.write('The tests in this directory were imported from the W3C repository.\n')
430         import_log.write('Do NOT modify these tests directly in Webkit. Instead, push changes to the W3C CSS repo:\n\n')
431         import_log.write('http://hg.csswg.org/test\n\n')
432         import_log.write('Then run the Tools/Scripts/import-w3c-tests in Webkit to reimport\n\n')
433         import_log.write('Do NOT modify or remove this file\n\n')
434         import_log.write('------------------------------------------------------------------------\n')
435         import_log.write('Last Import: ' + now.strftime('%Y-%m-%d %H:%M') + '\n')
436         import_log.write('W3C Mercurial changeset: ' + self.changeset + '\n')
437         import_log.write('Test status at time of import: ' + self.test_status + '\n')
438         import_log.write('------------------------------------------------------------------------\n')
439         import_log.write('Properties requiring vendor prefixes:\n')
440         if prop_list:
441             for prop in prop_list:
442                 import_log.write(prop + '\n')
443         else:
444             import_log.write('None\n')
445         import_log.write('------------------------------------------------------------------------\n')
446         import_log.write('List of files:\n')
447         for item in file_list:
448             import_log.write(item + '\n')
449
450         import_log.close()