| # Copyright (C) 2013 Google Inc. All rights reserved. |
| # |
| # Redistribution and use in source and binary forms, with or without |
| # modification, are permitted provided that the following conditions are |
| # met: |
| # |
| # * Redistributions of source code must retain the above copyright |
| # notice, this list of conditions and the following disclaimer. |
| # * Redistributions in binary form must reproduce the above |
| # copyright notice, this list of conditions and the following disclaimer |
| # in the documentation and/or other materials provided with the |
| # distribution. |
| # * Neither the name of Google Inc. nor the names of its |
| # contributors may be used to endorse or promote products derived from |
| # this software without specific prior written permission. |
| # |
| # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS |
| # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT |
| # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR |
| # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT |
| # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, |
| # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT |
| # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, |
| # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY |
| # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT |
| # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE |
| # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
| |
| """Moves a directory of LayoutTests. |
| |
| Given a path to a directory of LayoutTests, moves that directory, including all recursive children, |
| to the specified destination path. Updates all references in tests and resources to reflect the new |
| location. Also moves any corresponding platform-specific expected results and updates the test |
| expectations to reflect the move. |
| |
| If the destination directory does not exist, it and any missing parent directories are created. If |
| the destination directory already exists, the child members of the origin directory are added to the |
| destination directory. If any of the child members clash with existing members of the destination |
| directory, the move fails. |
| |
| Note that when new entries are added to the test expectations, no attempt is made to group or merge |
| them with existing entries. This should be be done manually and with lint-test-expectations. |
| """ |
| |
| import copy |
| import logging |
| import optparse |
| import os |
| import re |
| import urlparse |
| |
| from webkitpy.common.checkout.scm.detection import SCMDetector |
| from webkitpy.common.host import Host |
| from webkitpy.common.system.executive import Executive |
| from webkitpy.common.system.filesystem import FileSystem |
| from webkitpy.layout_tests.port.base import Port |
| from webkitpy.layout_tests.models.test_expectations import TestExpectations |
| |
| |
| logging.basicConfig() |
| _log = logging.getLogger(__name__) |
| _log.setLevel(logging.INFO) |
| |
| PLATFORM_DIRECTORY = 'platform' |
| |
| class LayoutTestsMover(object): |
| |
| def __init__(self, port=None): |
| self._port = port |
| if not self._port: |
| host = Host() |
| # Given that we use include_overrides=False and model_all_expectations=True when |
| # constructing the TestExpectations object, it doesn't matter which Port object we use. |
| self._port = host.port_factory.get() |
| self._port.host.initialize_scm() |
| self._filesystem = self._port.host.filesystem |
| self._scm = self._port.host.scm() |
| self._layout_tests_root = self._port.layout_tests_dir() |
| |
| def _scm_path(self, *paths): |
| return self._filesystem.join('LayoutTests', *paths) |
| |
| def _is_child_path(self, parent, possible_child): |
| normalized_parent = self._filesystem.normpath(parent) |
| normalized_child = self._filesystem.normpath(possible_child) |
| # We need to add a trailing separator to parent to avoid returning true for cases like |
| # parent='/foo/b', and possible_child='/foo/bar/baz'. |
| return normalized_parent == normalized_child or normalized_child.startswith(normalized_parent + self._filesystem.sep) |
| |
| def _move_path(self, path, origin, destination): |
| if not self._is_child_path(origin, path): |
| return path |
| return self._filesystem.normpath(self._filesystem.join(destination, self._filesystem.relpath(path, origin))) |
| |
| def _validate_input(self): |
| if not self._filesystem.isdir(self._absolute_origin): |
| raise Exception('Source path %s is not a directory' % self._origin) |
| if not self._is_child_path(self._layout_tests_root, self._absolute_origin): |
| raise Exception('Source path %s is not in LayoutTests directory' % self._origin) |
| if self._filesystem.isfile(self._absolute_destination): |
| raise Exception('Destination path %s is a file' % self._destination) |
| if not self._is_child_path(self._layout_tests_root, self._absolute_destination): |
| raise Exception('Destination path %s is not in LayoutTests directory' % self._destination) |
| |
| # If destination is an existing directory, we move the children of origin into destination. |
| # However, if any of the children of origin would clash with existing children of |
| # destination, we fail. |
| # FIXME: Consider adding support for recursively moving into an existing directory. |
| if self._filesystem.isdir(self._absolute_destination): |
| for file_path in self._filesystem.listdir(self._absolute_origin): |
| if self._filesystem.exists(self._filesystem.join(self._absolute_destination, file_path)): |
| raise Exception('Origin path %s clashes with existing destination path %s' % |
| (self._filesystem.join(self._origin, file_path), self._filesystem.join(self._destination, file_path))) |
| |
| def _get_expectations_for_test(self, model, test_path): |
| """Given a TestExpectationsModel object, finds all expectations that match the specified |
| test, specified as a relative path. Handles the fact that expectations may be keyed by |
| directory. |
| """ |
| expectations = set() |
| if model.has_test(test_path): |
| expectations.add(model.get_expectation_line(test_path)) |
| test_path = self._filesystem.dirname(test_path) |
| while not test_path == '': |
| # The model requires a trailing slash for directories. |
| test_path_for_model = test_path + '/' |
| if model.has_test(test_path_for_model): |
| expectations.add(model.get_expectation_line(test_path_for_model)) |
| test_path = self._filesystem.dirname(test_path) |
| return expectations |
| |
| def _get_expectations(self, model, path): |
| """Given a TestExpectationsModel object, finds all expectations for all tests under the |
| specified relative path. |
| """ |
| expectations = set() |
| for test in self._filesystem.files_under(self._filesystem.join(self._layout_tests_root, path), dirs_to_skip=['script-tests', 'resources'], |
| file_filter=Port.is_test_file): |
| expectations = expectations.union(self._get_expectations_for_test(model, self._filesystem.relpath(test, self._layout_tests_root))) |
| return expectations |
| |
| @staticmethod |
| def _clone_expectation_line_for_path(expectation_line, path): |
| """Clones a TestExpectationLine object and updates the clone to apply to the specified |
| relative path. |
| """ |
| clone = copy.copy(expectation_line) |
| clone.original_string = re.compile(expectation_line.name).sub(path, expectation_line.original_string) |
| clone.name = path |
| clone.path = path |
| # FIXME: Should we search existing expectations for matches, like in |
| # TestExpectationsParser._collect_matching_tests()? |
| clone.matching_tests = [path] |
| return clone |
| |
| def _update_expectations(self): |
| """Updates all test expectations that are affected by the move. |
| """ |
| _log.info('Updating expectations') |
| test_expectations = TestExpectations(self._port, include_overrides=False, model_all_expectations=True) |
| |
| for expectation in self._get_expectations(test_expectations.model(), self._origin): |
| path = expectation.path |
| if self._is_child_path(self._origin, path): |
| # If the existing expectation is a child of the moved path, we simply replace it |
| # with an expectation for the updated path. |
| new_path = self._move_path(path, self._origin, self._destination) |
| _log.debug('Updating expectation for %s to %s' % (path, new_path)) |
| test_expectations.remove_expectation_line(path) |
| test_expectations.add_expectation_line(LayoutTestsMover._clone_expectation_line_for_path(expectation, new_path)) |
| else: |
| # If the existing expectation is not a child of the moved path, we have to leave it |
| # in place. But we also add a new expectation for the destination path. |
| new_path = self._destination |
| _log.warning('Copying expectation for %s to %s. You should check that these expectations are still correct.' % |
| (path, new_path)) |
| test_expectations.add_expectation_line(LayoutTestsMover._clone_expectation_line_for_path(expectation, new_path)) |
| |
| expectations_file = self._port.path_to_generic_test_expectations_file() |
| self._filesystem.write_text_file(expectations_file, |
| TestExpectations.list_to_string(test_expectations._expectations, reconstitute_only_these=[])) |
| self._scm.add(self._filesystem.relpath(expectations_file, self._scm.checkout_root)) |
| |
| def _find_references(self, input_files): |
| """Attempts to find all references to other files in the supplied list of files. Returns a |
| dictionary that maps from an absolute file path to an array of reference strings. |
| """ |
| reference_regex = re.compile(r'(?:(?:src=|href=|importScripts\(|url\()(?:"([^"]+)"|\'([^\']+)\')|url\(([^\)\'"]+)\))') |
| references = {} |
| for input_file in input_files: |
| matches = reference_regex.findall(self._filesystem.read_binary_file(input_file)) |
| if matches: |
| references[input_file] = [filter(None, match)[0] for match in matches] |
| return references |
| |
| def _get_updated_reference(self, root, reference): |
| """For a reference <reference> in a directory <root>, determines the updated reference. |
| Returns the the updated reference, or None if no update is required. |
| """ |
| # If the reference is an absolute path or url, it's safe. |
| if reference.startswith('/') or urlparse.urlparse(reference).scheme: |
| return None |
| |
| # Both the root path and the target of the reference my be subject to the move, so there are |
| # four cases to consider. In the case where both or neither are subject to the move, the |
| # reference doesn't need updating. |
| # |
| # This is true even if the reference includes superfluous dot segments which mention a moved |
| # directory, as dot segments are collapsed during URL normalization. For example, if |
| # foo.html contains a reference 'bar/../script.js', this remains valid (though ugly) even if |
| # bar/ is moved to baz/, because the reference is always normalized to 'script.js'. |
| absolute_reference = self._filesystem.normpath(self._filesystem.join(root, reference)) |
| if self._is_child_path(self._absolute_origin, root) == self._is_child_path(self._absolute_origin, absolute_reference): |
| return None; |
| |
| new_root = self._move_path(root, self._absolute_origin, self._absolute_destination) |
| new_absolute_reference = self._move_path(absolute_reference, self._absolute_origin, self._absolute_destination) |
| return self._filesystem.relpath(new_absolute_reference, new_root) |
| |
| def _get_all_updated_references(self, references): |
| """Determines the updated references due to the move. Returns a dictionary that maps from an |
| absolute file path to a dictionary that maps from a reference string to the corresponding |
| updated reference. |
| """ |
| updates = {} |
| for file_path in references.keys(): |
| root = self._filesystem.dirname(file_path) |
| # sript-tests/TEMPLATE.html files contain references which are written as if the file |
| # were in the parent directory. This special-casing is ugly, but there are plans to |
| # remove script-tests. |
| if root.endswith('script-tests') and file_path.endswith('TEMPLATE.html'): |
| root = self._filesystem.dirname(root) |
| local_updates = {} |
| for reference in references[file_path]: |
| update = self._get_updated_reference(root, reference) |
| if update: |
| local_updates[reference] = update |
| if local_updates: |
| updates[file_path] = local_updates |
| return updates |
| |
| def _update_file(self, path, updates): |
| contents = self._filesystem.read_binary_file(path) |
| # Note that this regex isn't quite as strict as that used to find the references, but this |
| # avoids the need for alternative match groups, which simplifies things. |
| for target in updates.keys(): |
| regex = re.compile(r'((?:src=|href=|importScripts\(|url\()["\']?)%s(["\']?)' % target) |
| contents = regex.sub(r'\1%s\2' % updates[target], contents) |
| self._filesystem.write_binary_file(path, contents) |
| self._scm.add(path) |
| |
| def _update_test_source_files(self): |
| def is_test_source_file(filesystem, dirname, basename): |
| pass_regex = re.compile(r'\.(css|js)$') |
| fail_regex = re.compile(r'-expected\.') |
| return (Port.is_test_file(filesystem, dirname, basename) or pass_regex.search(basename)) and not fail_regex.search(basename) |
| |
| test_source_files = self._filesystem.files_under(self._layout_tests_root, file_filter=is_test_source_file) |
| _log.info('Considering %s test source files for references' % len(test_source_files)) |
| references = self._find_references(test_source_files) |
| _log.info('Considering references in %s files' % len(references)) |
| updates = self._get_all_updated_references(references) |
| _log.info('Updating references in %s files' % len(updates)) |
| count = 0 |
| for file_path in updates.keys(): |
| self._update_file(file_path, updates[file_path]) |
| count += 1 |
| if count % 1000 == 0 or count == len(updates): |
| _log.debug('Updated references in %s files' % count) |
| |
| def _move_directory(self, origin, destination): |
| """Moves the directory <origin> to <destination>. If <destination> is a directory, moves the |
| children of <origin> into <destination>. Uses relative paths. |
| """ |
| absolute_origin = self._filesystem.join(self._layout_tests_root, origin) |
| if not self._filesystem.isdir(absolute_origin): |
| return |
| _log.info('Moving directory %s to %s' % (origin, destination)) |
| # Note that FileSystem.move() may silently overwrite existing files, but we |
| # check for this in _validate_input(). |
| absolute_destination = self._filesystem.join(self._layout_tests_root, destination) |
| self._filesystem.maybe_make_directory(absolute_destination) |
| for directory in self._filesystem.listdir(absolute_origin): |
| self._scm.move(self._scm_path(origin, directory), self._scm_path(destination, directory)) |
| self._filesystem.rmtree(absolute_origin) |
| |
| def _move_files(self): |
| """Moves the all files that correspond to the move, including platform-specific expected |
| results. |
| """ |
| self._move_directory(self._origin, self._destination) |
| for directory in self._filesystem.listdir(self._filesystem.join(self._layout_tests_root, PLATFORM_DIRECTORY)): |
| self._move_directory(self._filesystem.join(PLATFORM_DIRECTORY, directory, self._origin), |
| self._filesystem.join(PLATFORM_DIRECTORY, directory, self._destination)) |
| |
| def _commit_changes(self): |
| if not self._scm.supports_local_commits(): |
| return |
| title = 'Move LayoutTests directory %s to %s' % (self._origin, self._destination) |
| _log.info('Committing change \'%s\'' % title) |
| self._scm.commit_locally_with_message('%s\n\nThis commit was automatically generated by move-layout-tests.' % title, |
| commit_all_working_directory_changes=False) |
| |
| def move(self, origin, destination): |
| self._origin = origin |
| self._destination = destination |
| self._absolute_origin = self._filesystem.join(self._layout_tests_root, self._origin) |
| self._absolute_destination = self._filesystem.join(self._layout_tests_root, self._destination) |
| self._validate_input() |
| self._update_expectations() |
| self._update_test_source_files() |
| self._move_files() |
| # FIXME: Handle virtual test suites. |
| self._commit_changes() |
| |
| def main(argv): |
| parser = optparse.OptionParser(description=__doc__) |
| parser.add_option('--origin', |
| help=('The directory of tests to move, as a relative path from the LayoutTests directory.')) |
| parser.add_option('--destination', |
| help=('The new path for the directory of tests, as a relative path from the LayoutTests directory.')) |
| options, _ = parser.parse_args() |
| LayoutTestsMover().move(options.origin, options.destination) |