Create a script to import W3C tests
[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. .xht extensions are changed to .xhtml to make new-run-webkit-tests happy
69          2. Paths to testharness.js files are modified point to Webkit's copy of them in
70             LayoutTests/resources, using the correct relative path from the new location
71          3. All CSS properties requiring the -webkit-vendor prefix are prefixed - this current
72             list of what needs prefixes is read from Source/WebCore/CSS/CSSProperties.in
73          4. Each reftest has its own copy of its reference file following the naming conventions
74             new-run-webkit-tests expects
75          5. If a a reference files lives outside the directory of the test that uses it, it is checked
76             for paths to support files as it will be imported into a different relative position to the
77             test file (in the same directory)
78
79      - Upon completion, script outputs the total number tests imported, broken down by test type
80
81      - Also upon completion, each directory where files are imported will have w3c-import.log written
82        with a timestamp, the W3C Mercurial changeset if available, the list of CSS properties used that
83        require prefixes, the list of imported files, and guidance for future test modification and
84        maintenance.
85
86      - On subsequent imports, this file is read to determine if files have been removed in the newer changesets.
87        The script removes these files accordingly.
88 """
89 import datetime
90 import mimetypes
91 from optparse import OptionParser
92 import os
93 import shutil
94 import subprocess
95 import sys
96
97 from webkitpy.w3c.test_parser import TestParser
98 from webkitpy.w3c.test_converter import TestConverter
99
100 LAYOUT_TESTS_DIRECTORY = 'LayoutTests'
101 CSSWG_DIRECTORY = 'csswg'
102 RESOURCES_DIRECTORY = 'resources'
103 APPROVED = 'approved'
104 SUBMITTED = 'submitted'
105 UNKNOWN = 'unknown'
106 NOT_AVAILABLE = 'Not Available'
107
108
109 def main(argv, _, stderr):
110
111     parse_args()
112     import_dir = validate_import_directory()
113     test_importer = TestImporter(import_dir, options)
114     test_importer.do_import()
115
116
117 def parse_args():
118
119     global options
120     global args
121
122     parser = OptionParser(usage='usage: %prog [options] w3c_test_directory')
123     parser.add_option('-n', '--no-overwrite',
124                       #action='store_true',
125                       default=False,
126                       help='Flag to prevent duplicate test files from overwriting existing tests. By default, they will be overwritten')
127     parser.add_option('-a', '--all',
128                       action='store_true',
129                       default=False,
130                       help='Import all tests including reftests, JS tests, and manual/pixel tests. By default, only reftests and JS tests are imported')
131
132     (options, args) = parser.parse_args()
133
134     if len(args) != 1:
135         parser.error('Incorrect number of arguments')
136
137
138 def validate_import_directory():
139
140     import_dir = args[0]
141
142     # Make sure the w3c test directory exists
143     if not os.path.exists(import_dir):
144         sys.exit('Source directory %s not found!' % import_dir)
145
146     # Make sure the tests are officially submitted to the W3C, either approved or
147     # submitted following their directory naming conventions
148     if import_dir.find(APPROVED) == -1 and \
149        import_dir.find(SUBMITTED) == -1:
150         # If not pointed directly to the approved directory or to any submitted
151         # directory, check for a submitted subdirectory and go with that
152         import_dir = os.path.join(import_dir, 'submitted')
153         if not os.path.exists(os.path.join(import_dir)):
154             sys.exit('Unable to import tests that aren\'t approved or submitted to the W3C')
155
156     return import_dir
157
158
159 class TestImporter(object):
160
161     def __init__(self, source_directory, options):
162         self.options = options
163
164         self.source_directory = source_directory
165         self.webkit_root = __file__.split(os.path.sep + 'Tools')[0]
166         self.destination_directory = os.path.join(os.path.sep, self.webkit_root, LAYOUT_TESTS_DIRECTORY, CSSWG_DIRECTORY)
167
168         self.changeset = NOT_AVAILABLE
169         self.test_status = UNKNOWN
170
171         self.import_list = []
172
173     def do_import(self):
174         self.scan_source_directory(self.source_directory)
175         self.get_changeset()
176         self.import_tests()
177
178     def get_changeset(self):
179         """ Runs hg tip to get the current changeset and parses the output to get the changeset. If that doesn't workout for some reason, it just becomes 'Not Available' """
180
181         try:
182             # Assuming Mercurial is set up on the system, try to grab the changeset we're importing
183             proc = subprocess.Popen(['hg', 'tip'], env=os.environ, stdout=subprocess.PIPE, cwd=self.source_directory)
184             self.changeset = proc.stdout.readlines()[0]
185             self.changeset = self.changeset.split('changeset:')[1]
186         except:
187             # Oh well, we tried
188             self.changeset = 'Not Available'
189
190     def scan_source_directory(self, directory):
191         """ Walks the |directory| looking for HTML files that are importable tests. """
192
193         for root, dirs, files in os.walk(directory):
194
195             print 'Scanning ' + root + '...'
196             total_tests = 0
197             reftests = 0
198             jstests = 0
199
200             # Ignore any repo stuff
201             if '.git' in dirs:
202                 dirs.remove('.git')
203             if '.hg' in dirs:
204                 dirs.remove('.hg')
205             # archive and data dirs are internal csswg things that live in every approved directory
206             if 'data' in dirs:
207                 dirs.remove('data')
208             if 'archive' in dirs:
209                 dirs.remove('archive')
210
211             copy_list = []
212
213             for filename in files:
214
215                 fullpath = os.path.join(root, filename)
216
217                 # Only html or xml are considered tests
218                 mimetype = mimetypes.guess_type(fullpath)
219                 if 'html' in str(mimetype[0]) or 'xml' in str(mimetype[0]):
220
221                     test_parser = TestParser(vars(options), filename=fullpath)
222                     test_info = test_parser.analyze_test()
223
224                     if test_info is None:
225                         continue
226
227                     if 'reference' in test_info.keys():
228
229                         reftests += 1
230                         total_tests += 1
231                         test_basename = os.path.basename(test_info['test'])
232
233                         # Add the ref file, renaming it to Webkit's way
234                         # Note: The preferable way to handle this would be to just
235                         #       add support in Webkit's harness to read metadata
236                         #       rather than rely on a file naming convention. Since
237                         #       the CSSWG tests support many:one tests:reference,
238                         #       enabling metadata parsing in our harness would also
239                         #       reduce the number of files copied in. As this is
240                         #       implemented here, we are knowingly importing multiple
241                         #       copies of the same ref files to adhere to Webkit's way
242                         ref_file = os.path.splitext(test_basename)[0] + '-expected'
243                         ref_file += os.path.splitext(test_basename)[1]
244
245                         copy_list.append({'src': test_info['reference'], 'dest': ref_file})
246                         copy_list.append({'src': test_info['test'], 'dest': filename})
247
248                         # If there are ref support files, the destination is should now be relative to the
249                         # test file and new location of the ref file
250                         if 'refsupport' in test_info.keys():
251                             for support_file in test_info['refsupport']:
252                                 # Build the correct source path
253                                 source_file = os.path.join(os.path.dirname(test_info['reference']), support_file)
254                                 source_file = os.path.normpath(source_file)
255                                 # Keep the dest as it was
256                                 to_copy = {'src': source_file, 'dest': support_file}
257                                 # Only add it once
258                                 if not(to_copy in copy_list):
259                                     copy_list.append(to_copy)
260
261                     elif 'jstest' in test_info.keys():
262                         jstests += 1
263                         total_tests += 1
264                         copy_list.append({'src': fullpath, 'dest': filename})
265
266                     else:
267                         total_tests += 1
268                         copy_list.append({'src': fullpath, 'dest': filename})
269
270                 # Ignore dotfiles and random perl scripts that are in some dirs
271                 elif not(filename.startswith('.')) and not(filename.endswith('.pl')):
272                     copy_list.append({'src': fullpath, 'dest': filename})
273
274             if total_tests == 0:
275                 # If there are no tests in this directory, skip the support dir that lives in all
276                 # of the 'approved' folders as part of the automated build system
277                 if 'support' in dirs:
278                     dirs.remove('support')
279
280                 if len(copy_list) > 0:
281                     # Only add this directory to the list if there's something to import
282                     self.import_list.append({'dirname': root, 'copy_list': copy_list,
283                                             'reftests': reftests, 'jstests': jstests, 'total_tests': total_tests})
284
285     def import_tests(self):
286         """ Copies and converts the full list of importable tests into Webkit and logs what was imported in each directory """
287
288         # Only set up the destination directory if there's something to import
289         if len(self.import_list) > 0:
290             self.setup_destination_directory()
291
292         converter = TestConverter()
293         total_imported_tests = 0
294         total_imported_reftests = 0
295         total_imported_jstests = 0
296
297         for dir_to_copy in self.import_list:
298
299             total_imported_tests += dir_to_copy['total_tests']
300             total_imported_reftests += dir_to_copy['reftests']
301             total_imported_jstests += dir_to_copy['jstests']
302
303             prefixed_properties = []
304
305             if len(dir_to_copy['copy_list']) > 0:
306
307                 # Build the subpath starting with the approved/submitted directory
308                 orig_path = dir_to_copy['dirname']
309                 start = orig_path.find(self.test_status)
310                 new_subpath = orig_path[start:len(orig_path)]
311
312                 # Append the new subpath to the destination_directory
313                 new_path = os.path.join(self.destination_directory, new_subpath)
314
315                 # Create the destination subdirectories if not there
316                 if not(os.path.exists(new_path)):
317                     os.makedirs(new_path)
318
319                 copied_files = []
320
321                 for file_to_copy in dir_to_copy['copy_list']:
322
323                     orig_filepath = os.path.normpath(file_to_copy['src'])
324
325                     # Shouldn't be any directories on the list, but check to be safe
326                     if os.path.isdir(orig_filepath):
327                         continue
328
329                     # Don't choke if the file can't be found
330                     if not(os.path.exists(orig_filepath)):
331                         print 'Warning: ' + orig_filepath + ' not found. Possible error in the test.'
332                         continue
333
334                     # Append the correct destination filename
335                     new_filepath = os.path.join(new_path, file_to_copy['dest'])
336
337                     # Change extension to work in the WebKit harness
338                     new_filepath = new_filepath.replace('.xht', '.xhtml')
339
340                     # Make the directories needed
341                     if not(os.path.exists(os.path.dirname(new_filepath))):
342                         os.makedirs(os.path.dirname(new_filepath))
343
344                     # Don't overwrite existing tests if specified
345                     if options.no_overwrite is True and os.path.exists(new_filepath):
346                         print 'Skipping import of existing file ' + new_filepath
347                     else:
348                         # TODO: Maybe doing a file diff is in order here for existing files?
349                         #       In other words, there's no sense in overwriting identical files, but
350                         #       there's no harm in copying the identical thing.
351                         print 'Importing: ' + orig_filepath
352                         print '       As: ' + new_filepath
353
354                     # Only html, xml, or css should be converted
355                     # TODO: Eventually, so should js when support is added for this type of conversion
356                     mimetype = mimetypes.guess_type(orig_filepath)
357                     if 'html' in str(mimetype[0]) or \
358                        'xml' in str(mimetype[0])  or \
359                        'css' in str(mimetype[0]):
360
361                         # Convert for WebKit
362                         converted_file = converter.convert_for_webkit(new_path, filename=orig_filepath)
363
364                         if converted_file is None:
365                             # Straight copy if nothing's changed
366                             shutil.copyfile(orig_filepath, new_filepath)
367                         else:
368                             # Write out the converted test
369                             prefixed_properties.extend(set(converted_file[0]) - set(prefixed_properties))
370                             outfile = open(new_filepath, 'w')
371                             outfile.write(converted_file[1])
372                             outfile.close()
373                     else:
374                         shutil.copyfile(orig_filepath, new_filepath)
375
376                     copied_files.append(new_filepath.replace(self.webkit_root, ''))
377
378                 # Take care of anything that may have been removed since the last import
379                 self.remove_deleted_files(new_path, copied_files)
380
381                 # Drop some info about what just happened here
382                 self.write_import_log(new_path, copied_files, prefixed_properties)
383
384         print 'Import complete'
385
386         print 'IMPORTED ' + str(total_imported_tests) + ' TOTAL TESTS'
387         print 'Imported ' + str(total_imported_reftests) + ' reftests'
388         print 'Imported ' + str(total_imported_jstests) + ' JS tests'
389         print 'Imported ' + str(total_imported_tests - total_imported_jstests - total_imported_reftests) + ' pixel/manual tests'
390
391     def setup_destination_directory(self):
392         """ Creates a destination directory that mirrors that of the source approved or submitted directory """
393
394         # The test status is in the path, so grab it
395         self.get_test_status()
396
397         # Mirror the directory structure in the csswg repo under approved/ or submitted/
398         start = self.source_directory.find(self.test_status)
399         new_subpath = self.source_directory[start:len(self.source_directory)]
400
401         destination_directory = os.path.join(self.destination_directory, new_subpath)
402
403         if not os.path.exists(destination_directory):
404             os.makedirs(destination_directory)
405
406         print 'Tests will be imported into: ' + destination_directory
407
408     def get_test_status(self):
409         """ Sets the test status to either 'approved' or 'submitted' """
410
411         status = UNKNOWN
412
413         if self.source_directory.find(APPROVED) != -1:
414             status = APPROVED
415         elif self.source_directory.find(SUBMITTED) != -1:
416             status = SUBMITTED
417
418         self.test_status = status
419
420     def remove_deleted_files(self, import_directory, new_file_list):
421         """ Reads an import log in |import_directory| and compares it to the |new_file_list| and removes files that are not in the new list """
422
423         previous_file_list = []
424
425         # First check if there was a previous import here - there should be a log
426         import_log_file = os.path.join(import_directory, 'w3c-import.log')
427         if not(os.path.exists(import_log_file)):
428             return
429
430         import_log = open(import_log_file, 'r')
431         contents = import_log.readlines()
432
433         # Pull log the list of files from the previous import
434         if 'List of files\n' in contents:
435             list_idx = contents.index('List of files:\n') + 1
436             previous_file_list = contents[list_idx:]
437             previous_file_list = map(lambda s: s.strip(), previous_file_list)
438
439         deleted_files = set(previous_file_list) - set(new_file_list)
440
441         # Loop through and remove them all
442         for deleted_file in deleted_files:
443             print 'Deleting file removed from the W3C repo:' + deleted_file
444             deleted_file = os.path.join(self.webkit_root, deleted_file)
445             os.remove(deleted_file)
446
447         import_log.close()
448
449     def write_import_log(self, import_directory, file_list, prop_list):
450         """ Writes a w3c-import.log file in each directory with imported files. """
451
452         now = datetime.datetime.now()
453
454         # Create a log of this import with all sorts of good information
455         import_log = open(os.path.join(import_directory, 'w3c-import.log'), 'w')
456         import_log.write('The tests in this directory were imported from the W3C repository.\n')
457         import_log.write('Do NOT modify these tests directly in Webkit. Instead, push changes to the W3C CSS repo:\n\n')
458         import_log.write('http://hg.csswg.org/test\n\n')
459         import_log.write('Then run the Tools/Scripts/import-w3c-tests in Webkit to reimport\n\n')
460         import_log.write('Do NOT modify or remove this file\n\n')
461         import_log.write('------------------------------------------------------------------------\n')
462         import_log.write('Last Import: ' + now.strftime('%Y-%m-%d %H:%M') + '\n')
463         import_log.write('W3C Mercurial changeset: ' + self.changeset + '\n')
464         import_log.write('Test status at time of import: ' + self.test_status + '\n')
465         import_log.write('------------------------------------------------------------------------\n')
466         import_log.write('Properties requiring vendor prefixes:\n')
467         if len(prop_list) == 0:
468             import_log.write('None\n')
469         else:
470             for prop in prop_list:
471                 import_log.write(prop + '\n')
472         import_log.write('------------------------------------------------------------------------\n')
473         import_log.write('List of files:\n')
474         for item in file_list:
475             import_log.write(item + '\n')
476
477         import_log.close()