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