• 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
29A test_mapping_config.json file can be defined in the project directory to
30configure the generated TEST_MAPPING file, for example:
31
32    {
33        // Run tests in postsubmit instead of presubmit.
34        "postsubmit_tests":["foo"]
35    }
36
37"""
38
39import argparse
40import glob
41import json
42import os
43import platform
44import re
45import subprocess
46import sys
47from datetime import datetime
48from pathlib import Path
49
50# Some tests requires specific options. Consider fixing the upstream crate
51# before updating this dictionary.
52TEST_OPTIONS = {
53    "ring_test_tests_digest_tests": [{"test-timeout": "600000"}],
54    "ring_test_src_lib": [{"test-timeout": "100000"}],
55}
56
57# Groups to add tests to. "presubmit" runs x86_64 device tests+host tests, and
58# "presubmit-rust" runs arm64 device tests on physical devices.
59TEST_GROUPS = [
60    "presubmit",
61    "presubmit-rust",
62    "postsubmit",
63]
64
65# Excluded tests. These tests will be ignored by this script.
66TEST_EXCLUDE = [
67        "ash_test_src_lib",
68        "ash_test_tests_constant_size_arrays",
69        "ash_test_tests_display",
70        "shared_library_test_src_lib",
71        "vulkano_test_src_lib",
72
73        # These are helper binaries for aidl_integration_test
74        # and aren't actually meant to run as individual tests.
75        "aidl_test_rust_client",
76        "aidl_test_rust_service",
77        "aidl_test_rust_service_async",
78
79        # This is a helper binary for AuthFsHostTest and shouldn't
80        # be run directly.
81        "open_then_run",
82
83        # TODO: Remove when b/198197213 is closed.
84        "diced_client_test",
85
86        "CoverageRustSmokeTest",
87        "libtrusty-rs-tests",
88        "terminal-size_test_src_lib",
89]
90
91# Excluded modules.
92EXCLUDE_PATHS = [
93        "//external/adhd",
94        "//external/crosvm",
95        "//external/libchromeos-rs",
96        "//external/vm_tools"
97]
98
99LABEL_PAT = re.compile('^//(.*):.*$')
100EXTERNAL_PAT = re.compile('^//external/rust/')
101
102
103class UpdaterException(Exception):
104    """Exception generated by this script."""
105
106
107class Env(object):
108    """Env captures the execution environment.
109
110    It ensures this script is executed within an AOSP repository.
111
112    Attributes:
113      ANDROID_BUILD_TOP: A string representing the absolute path to the top
114        of the repository.
115    """
116    def __init__(self):
117        try:
118            self.ANDROID_BUILD_TOP = os.environ['ANDROID_BUILD_TOP']
119        except KeyError:
120            raise UpdaterException('$ANDROID_BUILD_TOP is not defined; you '
121                                   'must first source build/envsetup.sh and '
122                                   'select a target.')
123
124
125class Bazel(object):
126    """Bazel wrapper.
127
128    The wrapper is used to call bazel queryview and generate the list of
129    reverse dependencies.
130
131    Attributes:
132      path: The path to the bazel executable.
133    """
134    def __init__(self, env):
135        """Constructor.
136
137        Note that the current directory is changed to ANDROID_BUILD_TOP.
138
139        Args:
140          env: An instance of Env.
141
142        Raises:
143          UpdaterException: an error occurred while calling soong_ui.
144        """
145        if platform.system() != 'Linux':
146            raise UpdaterException('This script has only been tested on Linux.')
147        self.path = os.path.join(env.ANDROID_BUILD_TOP, "build", "bazel", "bin", "bazel")
148        soong_ui = os.path.join(env.ANDROID_BUILD_TOP, "build", "soong", "soong_ui.bash")
149
150        # soong_ui requires to be at the root of the repository.
151        os.chdir(env.ANDROID_BUILD_TOP)
152        print("Generating Bazel files...")
153        cmd = [soong_ui, "--make-mode", "bp2build"]
154        try:
155            subprocess.check_output(cmd, stderr=subprocess.STDOUT, text=True)
156        except subprocess.CalledProcessError as e:
157            raise UpdaterException('Unable to generate bazel workspace: ' + e.output)
158
159        print("Building Bazel Queryview. This can take a couple of minutes...")
160        cmd = [soong_ui, "--build-mode", "--all-modules", "--dir=.", "queryview"]
161        try:
162            subprocess.check_output(cmd, stderr=subprocess.STDOUT, text=True)
163        except subprocess.CalledProcessError as e:
164            raise UpdaterException('Unable to update TEST_MAPPING: ' + e.output)
165
166    def query_modules(self, path):
167        """Returns all modules for a given path."""
168        cmd = self.path + " query --config=queryview /" + path + ":all"
169        out = subprocess.check_output(cmd, shell=True, stderr=subprocess.DEVNULL, text=True).strip().split("\n")
170        modules = set()
171        for line in out:
172            # speed up by excluding unused modules.
173            if "windows_x86" in line:
174                continue
175            modules.add(line)
176        return modules
177
178    def query_rdeps(self, module):
179        """Returns all reverse dependencies for a single module."""
180        cmd = (self.path + " query --config=queryview \'rdeps(//..., " +
181                module + ")\' --output=label_kind")
182        out = (subprocess.check_output(cmd, shell=True, stderr=subprocess.DEVNULL, text=True)
183                .strip().split("\n"))
184        if '' in out:
185            out.remove('')
186        return out
187
188    def exclude_module(self, module):
189        for path in EXCLUDE_PATHS:
190            if module.startswith(path):
191                return True
192        return False
193
194    # Return all the TEST_MAPPING files within a given path.
195    def find_all_test_mapping_files(self, path):
196        result = []
197        for root, dirs, files in os.walk(path):
198            if "TEST_MAPPING" in files:
199                result.append(os.path.join(root, "TEST_MAPPING"))
200        return result
201
202    # For a given test, return the TEST_MAPPING file where the test is mapped.
203    # This limits the search to the directory specified in "path" along with its subdirs.
204    def test_to_test_mapping(self, env, path, test):
205        test_mapping_files = self.find_all_test_mapping_files(env.ANDROID_BUILD_TOP + path)
206        for file in test_mapping_files:
207            with open(file) as fd:
208                if "\""+ test + "\"" in fd.read():
209                    mapping_path = file.split("/TEST_MAPPING")[0].split("//")[1]
210                    return mapping_path
211
212        return None
213
214    # Returns:
215    # rdep_test: for tests specified locally.
216    # rdep_dirs: for paths to TEST_MAPPING files for reverse dependencies.
217    #
218    # We import directories for non-local tests because including tests directly has proven to be
219    # fragile and burdensome. For example, whenever a project removes or renames a test, all the
220    # TEST_MAPPING files for its reverse dependencies must be updated or we get test breakages.
221    # That can be many tens of projects that must updated to prevent the reported breakage of tests
222    # that no longer exist. Similarly when a test is added, it won't be run when the reverse
223    # dependencies change unless/until update_crate_tests.py is run for its depenencies.
224    # Importing TEST_MAPPING files instead of tests solves both of these problems. When tests are
225    # removed, renamed, or added, only files local to the project need to be modified.
226    # The downside is that we potentially miss some tests. But this seems like a reasonable
227    # tradeoff.
228    def query_rdep_tests_dirs(self, env, modules, path, exclude_dir):
229        """Returns all reverse dependency tests for modules in this package."""
230        rdep_tests = set()
231        rdep_dirs = set()
232        path_pat = re.compile("^/%s:.*$" % path)
233        for module in modules:
234            for rdep in self.query_rdeps(module):
235                rule_type, _, mod = rdep.split(" ")
236                if rule_type == "rust_test_" or rule_type == "rust_test":
237                    if self.exclude_module(mod):
238                        continue
239                    path_match = path_pat.match(mod)
240                    if path_match or not EXTERNAL_PAT.match(mod):
241                        rdep_path = mod.split(":")[0]
242                        rdep_test = mod.split(":")[1].split("--")[0]
243                        mapping_path = self.test_to_test_mapping(env, rdep_path, rdep_test)
244                        # Only include tests directly if they're local to the project.
245                        if (mapping_path is not None) and exclude_dir.endswith(mapping_path):
246                            rdep_tests.add(rdep_test)
247                        # All other tests are included by path.
248                        elif mapping_path is not None:
249                            rdep_dirs.add(mapping_path)
250                    else:
251                        label_match = LABEL_PAT.match(mod)
252                        if label_match:
253                            rdep_dirs.add(label_match.group(1))
254        return (rdep_tests, rdep_dirs)
255
256
257class Package(object):
258    """A Bazel package.
259
260    Attributes:
261      dir: The absolute path to this package.
262      dir_rel: The relative path to this package.
263      rdep_tests: The list of computed reverse dependencies.
264      rdep_dirs: The list of computed reverse dependency directories.
265    """
266    def __init__(self, path, env, bazel):
267        """Constructor.
268
269        Note that the current directory is changed to the package location when
270        called.
271
272        Args:
273          path: Path to the package.
274          env: An instance of Env.
275          bazel: An instance of Bazel.
276
277        Raises:
278          UpdaterException: the package does not appear to belong to the
279            current repository.
280        """
281        self.dir = path
282        try:
283            self.dir_rel = self.dir.split(env.ANDROID_BUILD_TOP)[1]
284        except IndexError:
285            raise UpdaterException('The path ' + self.dir + ' is not under ' +
286                            env.ANDROID_BUILD_TOP + '; You must be in the '
287                            'directory of a crate or pass its absolute path '
288                            'as the argument.')
289
290        # Move to the package_directory.
291        os.chdir(self.dir)
292        modules = bazel.query_modules(self.dir_rel)
293        (self.rdep_tests, self.rdep_dirs) = bazel.query_rdep_tests_dirs(env, modules,
294                                                                        self.dir_rel, self.dir)
295
296    def get_rdep_tests_dirs(self):
297        return (self.rdep_tests, self.rdep_dirs)
298
299
300class TestMapping(object):
301    """A TEST_MAPPING file.
302
303    Attributes:
304      package: The package associated with this TEST_MAPPING file.
305    """
306    def __init__(self, env, bazel, path):
307        """Constructor.
308
309        Args:
310          env: An instance of Env.
311          bazel: An instance of Bazel.
312          path: The absolute path to the package.
313        """
314        self.package = Package(path, env, bazel)
315
316    def create(self):
317        """Generates the TEST_MAPPING file."""
318        (tests, dirs) = self.package.get_rdep_tests_dirs()
319        if not bool(tests) and not bool(dirs):
320            if os.path.isfile('TEST_MAPPING'):
321                os.remove('TEST_MAPPING')
322            return
323        test_mapping = self.tests_dirs_to_mapping(tests, dirs)
324        self.write_test_mapping(test_mapping)
325
326    def tests_dirs_to_mapping(self, tests, dirs):
327        """Translate the test list into a dictionary."""
328        test_mapping = {"imports": []}
329        config = None
330        if os.path.isfile(os.path.join(self.package.dir, "test_mapping_config.json")):
331            with open(os.path.join(self.package.dir, "test_mapping_config.json"), 'r') as fd:
332                config = json.load(fd)
333
334        for test_group in TEST_GROUPS:
335            test_mapping[test_group] = []
336            for test in tests:
337                if test in TEST_EXCLUDE:
338                    continue
339                if config and 'postsubmit_tests' in config:
340                    if test in config['postsubmit_tests'] and 'postsubmit' not in test_group:
341                        continue
342                    if test not in config['postsubmit_tests'] and 'postsubmit' in test_group:
343                        continue
344                else:
345                    if 'postsubmit' in test_group:
346                        # If postsubmit_tests is not configured, do not place
347                        # anything in postsubmit - presubmit groups are
348                        # automatically included in postsubmit in CI.
349                        continue
350                if test in TEST_OPTIONS:
351                    test_mapping[test_group].append({"name": test, "options": TEST_OPTIONS[test]})
352                else:
353                    test_mapping[test_group].append({"name": test})
354            test_mapping[test_group] = sorted(test_mapping[test_group], key=lambda t: t["name"])
355
356        for dir in dirs:
357            test_mapping["imports"].append({"path": dir})
358        test_mapping["imports"] = sorted(test_mapping["imports"], key=lambda t: t["path"])
359        test_mapping = {section: entry for (section, entry) in test_mapping.items() if entry}
360        return test_mapping
361
362    def write_test_mapping(self, test_mapping):
363        """Writes the TEST_MAPPING file."""
364        with open("TEST_MAPPING", "w") as json_file:
365            json_file.write("// Generated by update_crate_tests.py for tests that depend on this crate.\n")
366            json.dump(test_mapping, json_file, indent=2, separators=(',', ': '), sort_keys=True)
367            json_file.write("\n")
368        print("TEST_MAPPING successfully updated for %s!" % self.package.dir_rel)
369
370
371def parse_args():
372    parser = argparse.ArgumentParser('update_crate_tests')
373    parser.add_argument('paths',
374                        nargs='*',
375                        help='Absolute or relative paths of the projects as globs.')
376    parser.add_argument('--branch_and_commit',
377                        action='store_true',
378                        help='Starts a new branch and commit changes.')
379    parser.add_argument('--push_change',
380                        action='store_true',
381                        help='Pushes change to Gerrit.')
382    return parser.parse_args()
383
384def main():
385    args = parse_args()
386    paths = args.paths if len(args.paths) > 0 else [os.getcwd()]
387    # We want to use glob to get all the paths, so we first convert to absolute.
388    paths = [Path(path).resolve() for path in paths]
389    paths = sorted([path for abs_path in paths
390                    for path in glob.glob(str(abs_path))])
391
392    env = Env()
393    bazel = Bazel(env)
394    for path in paths:
395        try:
396            test_mapping = TestMapping(env, bazel, path)
397            test_mapping.create()
398            changed = (subprocess.call(['git', 'diff', '--quiet']) == 1)
399            untracked = (os.path.isfile('TEST_MAPPING') and
400                         (subprocess.run(['git', 'ls-files', '--error-unmatch', 'TEST_MAPPING'],
401                                         stderr=subprocess.DEVNULL,
402                                         stdout=subprocess.DEVNULL).returncode == 1))
403            if args.branch_and_commit and (changed or untracked):
404                subprocess.check_output(['repo', 'start',
405                                         'tmp_auto_test_mapping', '.'])
406                subprocess.check_output(['git', 'add', 'TEST_MAPPING'])
407                # test_mapping_config.json is not always present
408                subprocess.call(['git', 'add', 'test_mapping_config.json'],
409                                stderr=subprocess.DEVNULL,
410                                stdout=subprocess.DEVNULL)
411                subprocess.check_output(['git', 'commit', '-m',
412                                         'Update TEST_MAPPING\n\nTest: None'])
413            if args.push_change and (changed or untracked):
414                date = datetime.today().strftime('%m-%d')
415                subprocess.check_output(['git', 'push', 'aosp', 'HEAD:refs/for/master',
416                                         '-o', 'topic=test-mapping-%s' % date])
417        except (UpdaterException, subprocess.CalledProcessError) as err:
418            sys.exit("Error: " + str(err))
419
420if __name__ == '__main__':
421  main()
422