chromium/testing/scripts/rust/test_filtering.py

# Copyright 2021 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""This is a library for handling of --isolated-script-test-filter and
--isolated-script-test-filter-file cmdline arguments, as specified by
//docs/testing/test_executable_api.md

Typical usage:
    import argparse
    import test_filtering

    cmdline_parser = argparse.ArgumentParser()
    test_filtering.add_cmdline_args(cmdline_parser)
    ... adding other cmdline parameter definitions ...
    parsed_cmdline_args = cmdline_parser.parse_args()

    list_of_all_test_names = ... queried from the wrapped test executable ...
    list_of_test_names_to_run = test_filtering.filter_tests(
        parsed_cmdline_args, list_of_all_test_names)
"""

import re


class _TestFilter:
    """_TestFilter represents a single test filter pattern like foo (including
    'foo' test in the test run), bar* (including all tests with a name starting
    with 'bar'), or -baz (excluding 'baz' test from the test run).
    """

    def __init__(self, filter_text):
        assert '::' not in filter_text
        if '*' in filter_text[:-1]:
            raise ValueError('* is only allowed at the end (as documented ' \
                             'in //docs/testing/test_executable_api.md).')

        if filter_text.startswith('-'):
            self._is_exclusion_filter = True
            filter_text = filter_text[1:]
        else:
            self._is_exclusion_filter = False

        if filter_text.endswith('*'):
            self._is_prefix_match = True
            filter_text = filter_text[:-1]
        else:
            self._is_prefix_match = False

        self._filter_text = filter_text

    def is_match(self, test_name):
        """Returns whether the test filter should apply to `test_name`.
        """
        if self._is_prefix_match:
            return test_name.startswith(self._filter_text)
        return test_name == self._filter_text

    def is_exclusion_filter(self):
        """Rreturns whether this filter excludes (rather than includes) matching
        test names.
        """
        return self._is_exclusion_filter

    def get_specificity_key(self):
        """Returns a key that can be used to sort the TestFilter objects by
        their specificity.  From //docs/testing/test_executable_api.md:
        If multiple filters [...] match a given test name, the longest match
        takes priority (longest match wins). [...] It is an error to have
        multiple expressions of the same length that conflict (e.g., a*::-a*).
        """
        return (len(self._filter_text), self._filter_text)

    def __str__(self):
        result = self._filter_text
        if self._is_exclusion_filter:
            result = '-' + result
        if self._is_prefix_match:
            result += '*'
        return result


class _TestFiltersGroup:
    """_TestFiltersGroup represents an individual group of test filters
    (corresponding to a single --isolated-script-test-filter or
    --isolated-script-test-filter-file cmdline argument).
    """

    def __init__(self, list_of_test_filters):
        """Internal implementation detail - please use from_string and/or
        from_filter_file static methods instead."""
        self._list_of_test_filters = sorted(
            list_of_test_filters,
            key=lambda x: x.get_specificity_key(),
            reverse=True)

        if all(f.is_exclusion_filter() for f in self._list_of_test_filters):
            self._list_of_test_filters.append(_TestFilter('*'))
        assert len(list_of_test_filters)

        for i in range(len(self._list_of_test_filters) - 1):
            prev = self._list_of_test_filters[i]
            curr = self._list_of_test_filters[i + 1]
            if prev.get_specificity_key() == curr.get_specificity_key():
                raise ValueError(
                    'It is an error to have multiple test filters of the ' \
                    'same length that conflict (e.g., a*::-a*).  Conflicting ' \
                    'filters: {} and {}'.format(prev, curr))

    @staticmethod
    def from_string(cmdline_arg):
        """Constructs a _TestFiltersGroup from a string that follows the format
        of --isolated-script-test-filter cmdline argument as described in
        Chromium's //docs/testing/test_executable_api.md
        """
        list_of_test_filters = []
        for filter_text in cmdline_arg.split('::'):
            list_of_test_filters.append(_TestFilter(filter_text))
        return _TestFiltersGroup(list_of_test_filters)

    @staticmethod
    def from_filter_file(filepath):
        """Constructs a _TestFiltersGroup from an input file that can be passed
        to the --isolated-script-test-filter-file cmdline argument as described
        Chromium's //docs/testing/test_executable_api.md.  The file format is
        described in bit.ly/chromium-test-list-format (aka go/test-list-format).
        """
        list_of_test_filters = []
        regex = r'  \[ [^]]* \]'  # [ foo ]
        regex += r'| Bug \( [^)]* \)'  # Bug(12345)
        regex += r'| crbug.com/\S*'  # crbug.com/12345
        regex += r'| skbug.com/\S*'  # skbug.com/12345
        regex += r'| webkit.org/\S*'  # webkit.org/12345
        compiled_regex = re.compile(regex, re.VERBOSE)
        with open(filepath, mode='r', encoding='utf-8') as f:
            for line in f.readlines():
                filter_text = line.split('#')[0]
                filter_text = compiled_regex.sub('', filter_text)
                filter_text = filter_text.strip()
                if filter_text:
                    list_of_test_filters.append(_TestFilter(filter_text))
        return _TestFiltersGroup(list_of_test_filters)

    def is_test_name_included(self, test_name):
        for test_filter in self._list_of_test_filters:
            if test_filter.is_match(test_name):
                return not test_filter.is_exclusion_filter()
        return False


class _SetOfTestFiltersGroups:
    def __init__(self, list_of_test_filter_groups):
        """Constructs _SetOfTestFiltersGroups from `list_of_test_filter_groups`.

        Args:
            list_of_test_filter_groups: A list of _TestFiltersGroup objects.
        """
        self._test_filters_groups = list_of_test_filter_groups

    def filter_test_names(self, list_of_test_names):
        return [
            t for t in list_of_test_names if self._is_test_name_included(t)
        ]

    def _is_test_name_included(self, test_name):
        for test_filters_group in self._test_filters_groups:
            if not test_filters_group.is_test_name_included(test_name):
                return False
        return True


def _shard_tests(list_of_test_names, env):
    # Defaulting to 0 for `shard_index` and to 1 for `total_shards`.
    shard_index = int(env.get('GTEST_SHARD_INDEX', 0))
    total_shards = int(env.get('GTEST_TOTAL_SHARDS', 1))
    assert shard_index < total_shards

    result = []
    for i, test_name in enumerate(list_of_test_names):
        if (i % total_shards) == shard_index:
            result.append(test_name)

    return result


def _filter_test_names(list_of_test_names, argparse_parsed_args):
    inline_filter_groups = [
        _TestFiltersGroup.from_string(s)
        for s in argparse_parsed_args.test_filters
    ]
    filter_file_groups = [
        _TestFiltersGroup.from_filter_file(f)
        for f in argparse_parsed_args.test_filter_files
    ]
    set_of_filter_groups = _SetOfTestFiltersGroups(inline_filter_groups +
                                                   filter_file_groups)
    return set_of_filter_groups.filter_test_names(list_of_test_names)


def add_cmdline_args(argparse_parser):
    """Adds test-filtering-specific cmdline parameter definitions to
    `argparse_parser`.

    Args:
        argparse_parser: An object of argparse.ArgumentParser type.
    """
    filter_help = 'A double-colon-separated list of strings, where each ' \
                  'string either uniquely identifies a full test name or is ' \
                  'a prefix plus a "*" on the end (to form a glob). If the ' \
                  'string has a "-" at the front, the test (or glob of ' \
                  'tests) will be skipped, not run.'
    argparse_parser.add_argument('--test-filter',
                                 '--isolated-script-test-filter',
                                 action='append',
                                 default=[],
                                 dest='test_filters',
                                 help=filter_help,
                                 metavar='TEST-NAME-PATTERNS')
    file_help = 'Path to a file with test filters in Chromium Test List ' \
                'Format. See also //testing/buildbot/filters/README.md and ' \
                'bit.ly/chromium-test-list-format'
    argparse_parser.add_argument('--test-filter-file',
                                 '--isolated-script-test-filter-file',
                                 action='append',
                                 default=[],
                                 dest='test_filter_files',
                                 help=file_help,
                                 metavar='FILEPATH')


def filter_tests(argparse_parsed_args, env, list_of_test_names):
    """Filters `list_of_test_names` as requested by the cmdline arguments
    and sharding-related environment variables.

    Args:
        argparse_parsed_arg: A result of an earlier call to
          argparse_parser.parse_args() call (where `argparse_parser` has been
          populated via an even earlier call to add_cmdline_args).
        env: a dictionary-like object (typically from `os.environ`).
        list_of_test_name: A list of strings (a list of test names).
    """
    filtered_names = _filter_test_names(list_of_test_names,
                                        argparse_parsed_args)
    sharded_names = _shard_tests(filtered_names, env)
    return sharded_names