• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/usr/bin/env python3
2#
3# Copyright (C) 2020 The Android Open Source Project
4#
5# Licensed under the Apache License, Version 2.0 (the "License");
6# you may not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9#      http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS,
13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14# See the License for the specific language governing permissions and
15# limitations under the License.
16"""Add or update tests to TEST_MAPPING.
17
18This script uses Bazel to find reverse dependencies on a crate and generates a
19TEST_MAPPING file. It accepts the absolute path to a crate as argument. If no
20argument is provided, it assumes the crate is the current directory.
21
22  Usage:
23  $ . build/envsetup.sh
24  $ lunch aosp_arm64-eng
25  $ update_crate_tests.py $ANDROID_BUILD_TOP/external/rust/crates/libc
26
27This script is automatically called by external_updater.
28"""
29
30import argparse
31import glob
32import json
33import os
34import platform
35import re
36import subprocess
37import sys
38from datetime import datetime
39from pathlib import Path
40
41# Some tests requires specific options. Consider fixing the upstream crate
42# before updating this dictionary.
43TEST_OPTIONS = {
44    "ring_test_tests_digest_tests": [{"test-timeout": "600000"}],
45    "ring_test_src_lib": [{"test-timeout": "100000"}],
46}
47
48# Groups to add tests to. "presubmit" runs x86_64 device tests+host tests, and
49# "presubmit-rust" runs arm64 device tests on physical devices.
50TEST_GROUPS = [
51    "presubmit",
52    "presubmit-rust"
53]
54
55# Excluded tests. These tests will be ignored by this script.
56TEST_EXCLUDE = [
57        "ash_test_src_lib",
58        "ash_test_tests_constant_size_arrays",
59        "ash_test_tests_display",
60        "shared_library_test_src_lib",
61        "vulkano_test_src_lib",
62
63        # These are helper binaries for aidl_integration_test
64        # and aren't actually meant to run as individual tests.
65        "aidl_test_rust_client",
66        "aidl_test_rust_service",
67        "aidl_test_rust_service_async",
68
69        # This is a helper binary for AuthFsHostTest and shouldn't
70        # be run directly.
71        "open_then_run",
72
73        # TODO: Remove when b/198197213 is closed.
74        "diced_client_test",
75]
76
77# Excluded modules.
78EXCLUDE_PATHS = [
79        "//external/adhd",
80        "//external/crosvm",
81        "//external/libchromeos-rs",
82        "//external/vm_tools"
83]
84
85LABEL_PAT = re.compile('^//(.*):.*$')
86EXTERNAL_PAT = re.compile('^//external/rust/')
87
88
89class UpdaterException(Exception):
90    """Exception generated by this script."""
91
92
93class Env(object):
94    """Env captures the execution environment.
95
96    It ensures this script is executed within an AOSP repository.
97
98    Attributes:
99      ANDROID_BUILD_TOP: A string representing the absolute path to the top
100        of the repository.
101    """
102    def __init__(self):
103        try:
104            self.ANDROID_BUILD_TOP = os.environ['ANDROID_BUILD_TOP']
105        except KeyError:
106            raise UpdaterException('$ANDROID_BUILD_TOP is not defined; you '
107                                   'must first source build/envsetup.sh and '
108                                   'select a target.')
109
110
111class Bazel(object):
112    """Bazel wrapper.
113
114    The wrapper is used to call bazel queryview and generate the list of
115    reverse dependencies.
116
117    Attributes:
118      path: The path to the bazel executable.
119    """
120    def __init__(self, env):
121        """Constructor.
122
123        Note that the current directory is changed to ANDROID_BUILD_TOP.
124
125        Args:
126          env: An instance of Env.
127
128        Raises:
129          UpdaterException: an error occurred while calling soong_ui.
130        """
131        if platform.system() != 'Linux':
132            raise UpdaterException('This script has only been tested on Linux.')
133        self.path = os.path.join(env.ANDROID_BUILD_TOP, "tools", "bazel")
134        soong_ui = os.path.join(env.ANDROID_BUILD_TOP, "build", "soong", "soong_ui.bash")
135
136        # soong_ui requires to be at the root of the repository.
137        os.chdir(env.ANDROID_BUILD_TOP)
138        print("Generating Bazel files...")
139        cmd = [soong_ui, "--make-mode", "bp2build"]
140        try:
141            subprocess.check_output(cmd, stderr=subprocess.STDOUT, text=True)
142        except subprocess.CalledProcessError as e:
143            raise UpdaterException('Unable to generate bazel workspace: ' + e.output)
144
145        print("Building Bazel Queryview. This can take a couple of minutes...")
146        cmd = [soong_ui, "--build-mode", "--all-modules", "--dir=.", "queryview"]
147        try:
148            subprocess.check_output(cmd, stderr=subprocess.STDOUT, text=True)
149        except subprocess.CalledProcessError as e:
150            raise UpdaterException('Unable to update TEST_MAPPING: ' + e.output)
151
152    def query_modules(self, path):
153        """Returns all modules for a given path."""
154        cmd = self.path + " query --config=queryview /" + path + ":all"
155        out = subprocess.check_output(cmd, shell=True, stderr=subprocess.DEVNULL, text=True).strip().split("\n")
156        modules = set()
157        for line in out:
158            # speed up by excluding unused modules.
159            if "windows_x86" in line:
160                continue
161            modules.add(line)
162        return modules
163
164    def query_rdeps(self, module):
165        """Returns all reverse dependencies for a single module."""
166        cmd = (self.path + " query --config=queryview \'rdeps(//..., " +
167                module + ")\' --output=label_kind")
168        out = (subprocess.check_output(cmd, shell=True, stderr=subprocess.DEVNULL, text=True)
169                .strip().split("\n"))
170        if '' in out:
171            out.remove('')
172        return out
173
174    def exclude_module(self, module):
175        for path in EXCLUDE_PATHS:
176            if module.startswith(path):
177                return True
178        return False
179
180    def query_rdep_tests_dirs(self, modules, path):
181        """Returns all reverse dependency tests for modules in this package."""
182        rdep_tests = set()
183        rdep_dirs = set()
184        path_pat = re.compile("^/%s:.*$" % path)
185        for module in modules:
186            for rdep in self.query_rdeps(module):
187                rule_type, _, mod = rdep.split(" ")
188                if rule_type == "rust_test_" or rule_type == "rust_test":
189                    if self.exclude_module(mod):
190                        continue
191                    path_match = path_pat.match(mod)
192                    if path_match or not EXTERNAL_PAT.match(mod):
193                        rdep_tests.add(mod.split(":")[1].split("--")[0])
194                    else:
195                        label_match = LABEL_PAT.match(mod)
196                        if label_match:
197                            rdep_dirs.add(label_match.group(1))
198        return (rdep_tests, rdep_dirs)
199
200
201class Package(object):
202    """A Bazel package.
203
204    Attributes:
205      dir: The absolute path to this package.
206      dir_rel: The relative path to this package.
207      rdep_tests: The list of computed reverse dependencies.
208      rdep_dirs: The list of computed reverse dependency directories.
209    """
210    def __init__(self, path, env, bazel):
211        """Constructor.
212
213        Note that the current directory is changed to the package location when
214        called.
215
216        Args:
217          path: Path to the package.
218          env: An instance of Env.
219          bazel: An instance of Bazel.
220
221        Raises:
222          UpdaterException: the package does not appear to belong to the
223            current repository.
224        """
225        self.dir = path
226        try:
227            self.dir_rel = self.dir.split(env.ANDROID_BUILD_TOP)[1]
228        except IndexError:
229            raise UpdaterException('The path ' + self.dir + ' is not under ' +
230                            env.ANDROID_BUILD_TOP + '; You must be in the '
231                            'directory of a crate or pass its absolute path '
232                            'as the argument.')
233
234        # Move to the package_directory.
235        os.chdir(self.dir)
236        modules = bazel.query_modules(self.dir_rel)
237        (self.rdep_tests, self.rdep_dirs) = bazel.query_rdep_tests_dirs(modules, self.dir_rel)
238
239    def get_rdep_tests_dirs(self):
240        return (self.rdep_tests, self.rdep_dirs)
241
242
243class TestMapping(object):
244    """A TEST_MAPPING file.
245
246    Attributes:
247      package: The package associated with this TEST_MAPPING file.
248    """
249    def __init__(self, env, bazel, path):
250        """Constructor.
251
252        Args:
253          env: An instance of Env.
254          bazel: An instance of Bazel.
255          path: The absolute path to the package.
256        """
257        self.package = Package(path, env, bazel)
258
259    def create(self):
260        """Generates the TEST_MAPPING file."""
261        (tests, dirs) = self.package.get_rdep_tests_dirs()
262        if not bool(tests) and not bool(dirs):
263            if os.path.isfile('TEST_MAPPING'):
264                os.remove('TEST_MAPPING')
265            return
266        test_mapping = self.tests_dirs_to_mapping(tests, dirs)
267        self.write_test_mapping(test_mapping)
268
269    def tests_dirs_to_mapping(self, tests, dirs):
270        """Translate the test list into a dictionary."""
271        test_mapping = {"imports": []}
272        for test_group in TEST_GROUPS:
273            test_mapping[test_group] = []
274            for test in tests:
275                if test in TEST_EXCLUDE:
276                    continue
277                if test in TEST_OPTIONS:
278                    test_mapping[test_group].append({"name": test, "options": TEST_OPTIONS[test]})
279                else:
280                    test_mapping[test_group].append({"name": test})
281            test_mapping[test_group] = sorted(test_mapping[test_group], key=lambda t: t["name"])
282        for dir in dirs:
283            test_mapping["imports"].append({"path": dir})
284        test_mapping["imports"] = sorted(test_mapping["imports"], key=lambda t: t["path"])
285        test_mapping = {section: entry for (section, entry) in test_mapping.items() if entry}
286        return test_mapping
287
288    def write_test_mapping(self, test_mapping):
289        """Writes the TEST_MAPPING file."""
290        with open("TEST_MAPPING", "w") as json_file:
291            json_file.write("// Generated by update_crate_tests.py for tests that depend on this crate.\n")
292            json.dump(test_mapping, json_file, indent=2, separators=(',', ': '), sort_keys=True)
293            json_file.write("\n")
294        print("TEST_MAPPING successfully updated for %s!" % self.package.dir_rel)
295
296
297def parse_args():
298    parser = argparse.ArgumentParser('update_crate_tests')
299    parser.add_argument('paths',
300                        nargs='*',
301                        help='Absolute or relative paths of the projects as globs.')
302    parser.add_argument('--branch_and_commit',
303                        action='store_true',
304                        help='Starts a new branch and commit changes.')
305    parser.add_argument('--push_change',
306                        action='store_true',
307                        help='Pushes change to Gerrit.')
308    return parser.parse_args()
309
310
311def main():
312    args = parse_args()
313    paths = args.paths if len(args.paths) > 0 else [os.getcwd()]
314    # We want to use glob to get all the paths, so we first convert to absolute.
315    paths = [Path(path).resolve() for path in paths]
316    paths = sorted([path for abs_path in paths
317                    for path in glob.glob(str(abs_path))])
318
319    env = Env()
320    bazel = Bazel(env)
321    for path in paths:
322        try:
323            test_mapping = TestMapping(env, bazel, path)
324            test_mapping.create()
325            changed = (subprocess.call(['git', 'diff', '--quiet']) == 1)
326            untracked = (os.path.isfile('TEST_MAPPING') and
327                         (subprocess.run(['git', 'ls-files', '--error-unmatch', 'TEST_MAPPING'],
328                                         stderr=subprocess.DEVNULL,
329                                         stdout=subprocess.DEVNULL).returncode == 1))
330            if args.branch_and_commit and (changed or untracked):
331                subprocess.check_output(['repo', 'start',
332                                         'tmp_auto_test_mapping', '.'])
333                subprocess.check_output(['git', 'add', 'TEST_MAPPING'])
334                subprocess.check_output(['git', 'commit', '-m',
335                                         'Update TEST_MAPPING\n\nTest: None'])
336            if args.push_change and (changed or untracked):
337                date = datetime.today().strftime('%m-%d')
338                subprocess.check_output(['git', 'push', 'aosp', 'HEAD:refs/for/master',
339                                         '-o', 'topic=test-mapping-%s' % date])
340        except (UpdaterException, subprocess.CalledProcessError) as err:
341            sys.exit("Error: " + str(err))
342
343if __name__ == '__main__':
344  main()
345