1# Copyright 2024, The Android Open Source Project 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); 4# you may not use this file except in compliance with the License. 5# You may obtain a copy of the License at 6# 7# http://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12# See the License for the specific language governing permissions and 13# limitations under the License. 14 15"""Tests for build_test_suites.py""" 16 17import argparse 18import functools 19from importlib import resources 20import json 21import multiprocessing 22import os 23import pathlib 24import shutil 25import signal 26import stat 27import subprocess 28import sys 29import tempfile 30import textwrap 31import time 32from typing import Callable 33import unittest 34from unittest import mock 35from build_context import BuildContext 36import build_test_suites 37import ci_test_lib 38import optimized_targets 39from pyfakefs import fake_filesystem_unittest 40import metrics_agent 41import test_discovery_agent 42 43 44class BuildTestSuitesTest(fake_filesystem_unittest.TestCase): 45 46 def setUp(self): 47 self.setUpPyfakefs() 48 49 os_environ_patcher = mock.patch.dict('os.environ', {}) 50 self.addCleanup(os_environ_patcher.stop) 51 self.mock_os_environ = os_environ_patcher.start() 52 53 subprocess_run_patcher = mock.patch('subprocess.run') 54 self.addCleanup(subprocess_run_patcher.stop) 55 self.mock_subprocess_run = subprocess_run_patcher.start() 56 57 metrics_agent_finalize_patcher = mock.patch('metrics_agent.MetricsAgent.end_reporting') 58 self.addCleanup(metrics_agent_finalize_patcher.stop) 59 self.mock_metrics_agent_end = metrics_agent_finalize_patcher.start() 60 61 self._setup_working_build_env() 62 63 def test_missing_target_release_env_var_raises(self): 64 del os.environ['TARGET_RELEASE'] 65 66 with self.assert_raises_word(build_test_suites.Error, 'TARGET_RELEASE'): 67 build_test_suites.main([]) 68 69 def test_missing_target_product_env_var_raises(self): 70 del os.environ['TARGET_PRODUCT'] 71 72 with self.assert_raises_word(build_test_suites.Error, 'TARGET_PRODUCT'): 73 build_test_suites.main([]) 74 75 def test_missing_top_env_var_raises(self): 76 del os.environ['TOP'] 77 78 with self.assert_raises_word(build_test_suites.Error, 'TOP'): 79 build_test_suites.main([]) 80 81 def test_missing_dist_dir_env_var_raises(self): 82 del os.environ['DIST_DIR'] 83 84 with self.assert_raises_word(build_test_suites.Error, 'DIST_DIR'): 85 build_test_suites.main([]) 86 87 def test_invalid_arg_raises(self): 88 invalid_args = ['--invalid_arg'] 89 90 with self.assertRaisesRegex(SystemExit, '2'): 91 build_test_suites.main(invalid_args) 92 93 def test_build_failure_returns(self): 94 self.mock_subprocess_run.side_effect = subprocess.CalledProcessError( 95 42, None 96 ) 97 98 with self.assertRaisesRegex(SystemExit, '42'): 99 build_test_suites.main([]) 100 101 def test_incorrectly_formatted_build_context_raises(self): 102 build_context = self.fake_top.joinpath('build_context') 103 build_context.touch() 104 os.environ['BUILD_CONTEXT'] = str(build_context) 105 106 with self.assert_raises_word(build_test_suites.Error, 'JSON'): 107 build_test_suites.main([]) 108 109 def test_build_success_returns(self): 110 with self.assertRaisesRegex(SystemExit, '0'): 111 build_test_suites.main([]) 112 113 def assert_raises_word(self, cls, word): 114 return self.assertRaisesRegex(cls, rf'\b{word}\b') 115 116 def _setup_working_build_env(self): 117 self.fake_top = pathlib.Path('/fake/top') 118 self.fake_top.mkdir(parents=True) 119 120 self.soong_ui_dir = self.fake_top.joinpath('build/soong') 121 self.soong_ui_dir.mkdir(parents=True, exist_ok=True) 122 123 self.logs_dir = self.fake_top.joinpath('dist/logs') 124 self.logs_dir.mkdir(parents=True, exist_ok=True) 125 126 self.soong_ui = self.soong_ui_dir.joinpath('soong_ui.bash') 127 self.soong_ui.touch() 128 129 self.mock_os_environ.update({ 130 'TARGET_RELEASE': 'release', 131 'TARGET_PRODUCT': 'product', 132 'TOP': str(self.fake_top), 133 'DIST_DIR': str(self.fake_top.joinpath('dist')), 134 }) 135 136 self.mock_subprocess_run.return_value = 0 137 138 139class RunCommandIntegrationTest(ci_test_lib.TestCase): 140 141 def setUp(self): 142 self.temp_dir = ci_test_lib.TestTemporaryDirectory.create(self) 143 144 # Copy the Python executable from 'non-code' resources and make it 145 # executable for use by tests that launch a subprocess. Note that we don't 146 # use Python's native `sys.executable` property since that is not set when 147 # running via the embedded launcher. 148 base_name = 'py3-cmd' 149 dest_file = self.temp_dir.joinpath(base_name) 150 with resources.as_file( 151 resources.files('testdata').joinpath(base_name) 152 ) as p: 153 shutil.copy(p, dest_file) 154 dest_file.chmod(dest_file.stat().st_mode | stat.S_IEXEC) 155 self.python_executable = dest_file 156 157 self._managed_processes = [] 158 159 def tearDown(self): 160 self._terminate_managed_processes() 161 162 def test_raises_on_nonzero_exit(self): 163 with self.assertRaises(Exception): 164 build_test_suites.run_command([ 165 self.python_executable, 166 '-c', 167 textwrap.dedent(f"""\ 168 import sys 169 sys.exit(1) 170 """), 171 ]) 172 173 def test_streams_stdout(self): 174 175 def run_slow_command(stdout_file, marker): 176 with open(stdout_file, 'w') as f: 177 build_test_suites.run_command( 178 [ 179 self.python_executable, 180 '-c', 181 textwrap.dedent(f"""\ 182 import time 183 184 print('{marker}', end='', flush=True) 185 186 # Keep process alive until we check stdout. 187 time.sleep(10) 188 """), 189 ], 190 stdout=f, 191 ) 192 193 marker = 'Spinach' 194 stdout_file = self.temp_dir.joinpath('stdout.txt') 195 196 p = self.start_process(target=run_slow_command, args=[stdout_file, marker]) 197 198 self.assert_file_eventually_contains(stdout_file, marker) 199 200 def test_propagates_interruptions(self): 201 202 def run(pid_file): 203 build_test_suites.run_command([ 204 self.python_executable, 205 '-c', 206 textwrap.dedent(f"""\ 207 import os 208 import pathlib 209 import time 210 211 pathlib.Path('{pid_file}').write_text(str(os.getpid())) 212 213 # Keep the process alive for us to explicitly interrupt it. 214 time.sleep(10) 215 """), 216 ]) 217 218 pid_file = self.temp_dir.joinpath('pid.txt') 219 p = self.start_process(target=run, args=[pid_file]) 220 subprocess_pid = int(read_eventual_file_contents(pid_file)) 221 222 os.kill(p.pid, signal.SIGINT) 223 p.join() 224 225 self.assert_process_eventually_dies(p.pid) 226 self.assert_process_eventually_dies(subprocess_pid) 227 228 def start_process(self, *args, **kwargs) -> multiprocessing.Process: 229 p = multiprocessing.Process(*args, **kwargs) 230 self._managed_processes.append(p) 231 p.start() 232 return p 233 234 def assert_process_eventually_dies(self, pid: int): 235 try: 236 wait_until(lambda: not ci_test_lib.process_alive(pid)) 237 except TimeoutError as e: 238 self.fail(f'Process {pid} did not die after a while: {e}') 239 240 def assert_file_eventually_contains(self, file: pathlib.Path, substring: str): 241 wait_until(lambda: file.is_file() and file.stat().st_size > 0) 242 self.assertIn(substring, read_file_contents(file)) 243 244 def _terminate_managed_processes(self): 245 for p in self._managed_processes: 246 if not p.is_alive(): 247 continue 248 249 # We terminate the process with `SIGINT` since using `terminate` or 250 # `SIGKILL` doesn't kill any grandchild processes and we don't have 251 # `psutil` available to easily query all children. 252 os.kill(p.pid, signal.SIGINT) 253 254 255class BuildPlannerTest(unittest.TestCase): 256 257 class TestOptimizedBuildTarget(optimized_targets.OptimizedBuildTarget): 258 259 def __init__( 260 self, target, build_context, args, test_infos, output_targets, packaging_commands 261 ): 262 super().__init__(target, build_context, args, test_infos) 263 self.output_targets = output_targets 264 self.packaging_commands = packaging_commands 265 266 def get_build_targets_impl(self): 267 return self.output_targets 268 269 def get_package_outputs_commands_impl(self): 270 return self.packaging_commands 271 272 def get_enabled_flag(self): 273 return f'{self.target}_enabled' 274 275 def setUp(self): 276 test_discovery_agent_patcher = mock.patch('test_discovery_agent.TestDiscoveryAgent.discover_test_zip_regexes') 277 self.addCleanup(test_discovery_agent_patcher.stop) 278 self.mock_test_discovery_agent_end = test_discovery_agent_patcher.start() 279 280 281 def test_build_optimization_off_builds_everything(self): 282 build_targets = {'target_1', 'target_2'} 283 build_planner = self.create_build_planner( 284 build_context=self.create_build_context(optimized_build_enabled=False), 285 build_targets=build_targets, 286 ) 287 288 build_plan = build_planner.create_build_plan() 289 290 self.assertSetEqual(build_targets, build_plan.build_targets) 291 292 def test_build_optimization_off_doesnt_package(self): 293 build_targets = {'target_1', 'target_2'} 294 build_planner = self.create_build_planner( 295 build_context=self.create_build_context(optimized_build_enabled=False), 296 build_targets=build_targets, 297 ) 298 299 build_plan = build_planner.create_build_plan() 300 301 for packaging_command in self.run_packaging_commands(build_plan): 302 self.assertEqual(len(packaging_command), 0) 303 304 def test_build_optimization_on_optimizes_target(self): 305 build_targets = {'target_1', 'target_2'} 306 build_planner = self.create_build_planner( 307 build_targets=build_targets, 308 build_context=self.create_build_context( 309 enabled_build_features=[{'name': self.get_target_flag('target_1')}], 310 test_context=self.get_test_context('target_1'), 311 ), 312 ) 313 314 build_plan = build_planner.create_build_plan() 315 316 expected_targets = {self.get_optimized_target_name('target_1'), 'target_2'} 317 self.assertSetEqual(expected_targets, build_plan.build_targets) 318 319 def test_build_optimization_on_packages_target(self): 320 build_targets = {'target_1', 'target_2'} 321 optimized_target_name = self.get_optimized_target_name('target_1') 322 packaging_commands = [[f'packaging {optimized_target_name}']] 323 build_planner = self.create_build_planner( 324 build_targets=build_targets, 325 build_context=self.create_build_context( 326 enabled_build_features=[{'name': self.get_target_flag('target_1')}], 327 test_context=self.get_test_context('target_1'), 328 ), 329 packaging_commands=packaging_commands, 330 ) 331 332 build_plan = build_planner.create_build_plan() 333 334 self.assertIn(packaging_commands, self.run_packaging_commands(build_plan)) 335 336 def test_individual_build_optimization_off_doesnt_optimize(self): 337 build_targets = {'target_1', 'target_2'} 338 build_planner = self.create_build_planner( 339 build_targets=build_targets, 340 ) 341 342 build_plan = build_planner.create_build_plan() 343 344 self.assertSetEqual(build_targets, build_plan.build_targets) 345 346 def test_individual_build_optimization_off_doesnt_package(self): 347 build_targets = {'target_1', 'target_2'} 348 packaging_commands = [['packaging command']] 349 build_planner = self.create_build_planner( 350 build_targets=build_targets, 351 packaging_commands=packaging_commands, 352 ) 353 354 build_plan = build_planner.create_build_plan() 355 356 for packaging_command in self.run_packaging_commands(build_plan): 357 self.assertEqual(len(packaging_command), 0) 358 359 def test_target_output_used_target_built(self): 360 build_target = 'test_target' 361 build_planner = self.create_build_planner( 362 build_targets={build_target}, 363 build_context=self.create_build_context( 364 test_context=self.get_test_context(build_target), 365 enabled_build_features=[{'name': 'test_target_unused_exclusion'}], 366 ), 367 ) 368 369 build_plan = build_planner.create_build_plan() 370 371 self.assertSetEqual(build_plan.build_targets, {build_target}) 372 373 def test_target_regex_used_target_built(self): 374 build_target = 'test_target' 375 test_context = self.get_test_context(build_target) 376 test_context['testInfos'][0]['extraOptions'] = [{ 377 'key': 'additional-files-filter', 378 'values': [f'.*{build_target}.*\.zip'], 379 }] 380 build_planner = self.create_build_planner( 381 build_targets={build_target}, 382 build_context=self.create_build_context( 383 test_context=test_context, 384 enabled_build_features=[{'name': 'test_target_unused_exclusion'}], 385 ), 386 ) 387 388 build_plan = build_planner.create_build_plan() 389 390 self.assertSetEqual(build_plan.build_targets, {build_target}) 391 392 def test_target_output_not_used_target_not_built(self): 393 build_target = 'test_target' 394 test_context = self.get_test_context(build_target) 395 test_context['testInfos'][0]['extraOptions'] = [] 396 build_planner = self.create_build_planner( 397 build_targets={build_target}, 398 build_context=self.create_build_context( 399 test_context=test_context, 400 enabled_build_features=[{'name': 'test_target_unused_exclusion'}], 401 ), 402 ) 403 404 build_plan = build_planner.create_build_plan() 405 406 self.assertSetEqual(build_plan.build_targets, set()) 407 408 def test_target_regex_matching_not_too_broad(self): 409 build_target = 'test_target' 410 test_context = self.get_test_context(build_target) 411 test_context['testInfos'][0]['extraOptions'] = [{ 412 'key': 'additional-files-filter', 413 'values': [f'.*a{build_target}.*\.zip'], 414 }] 415 build_planner = self.create_build_planner( 416 build_targets={build_target}, 417 build_context=self.create_build_context( 418 test_context=test_context, 419 enabled_build_features=[{'name': 'test_target_unused_exclusion'}], 420 ), 421 ) 422 423 build_plan = build_planner.create_build_plan() 424 425 self.assertSetEqual(build_plan.build_targets, set()) 426 427 def create_build_planner( 428 self, 429 build_targets: set[str], 430 build_context: BuildContext = None, 431 args: argparse.Namespace = None, 432 target_optimizations: dict[ 433 str, optimized_targets.OptimizedBuildTarget 434 ] = None, 435 packaging_commands: list[list[str]] = [], 436 ) -> build_test_suites.BuildPlanner: 437 if not build_context: 438 build_context = self.create_build_context() 439 if not args: 440 args = self.create_args(extra_build_targets=build_targets) 441 if not target_optimizations: 442 target_optimizations = self.create_target_optimizations( 443 build_context, 444 build_targets, 445 packaging_commands, 446 ) 447 return build_test_suites.BuildPlanner( 448 build_context, args, target_optimizations 449 ) 450 451 def create_build_context( 452 self, 453 optimized_build_enabled: bool = True, 454 enabled_build_features: list[dict[str, str]] = [], 455 test_context: dict[str, any] = {}, 456 ) -> BuildContext: 457 build_context_dict = {} 458 build_context_dict['enabledBuildFeatures'] = enabled_build_features 459 if optimized_build_enabled: 460 build_context_dict['enabledBuildFeatures'].append( 461 {'name': 'optimized_build'} 462 ) 463 build_context_dict['testContext'] = test_context 464 return BuildContext(build_context_dict) 465 466 def create_args( 467 self, extra_build_targets: set[str] = set() 468 ) -> argparse.Namespace: 469 parser = argparse.ArgumentParser() 470 parser.add_argument('extra_targets', nargs='*') 471 return parser.parse_args(extra_build_targets) 472 473 def create_target_optimizations( 474 self, 475 build_context: BuildContext, 476 build_targets: set[str], 477 packaging_commands: list[list[str]] = [], 478 ): 479 target_optimizations = dict() 480 for target in build_targets: 481 target_optimizations[target] = functools.partial( 482 self.TestOptimizedBuildTarget, 483 output_targets={self.get_optimized_target_name(target)}, 484 packaging_commands=packaging_commands, 485 ) 486 487 return target_optimizations 488 489 def get_target_flag(self, target: str): 490 return f'{target}_enabled' 491 492 def get_optimized_target_name(self, target: str): 493 return f'{target}_optimized' 494 495 def get_test_context(self, target: str): 496 return { 497 'testInfos': [ 498 { 499 'name': 'atp_test', 500 'target': 'test_target', 501 'branch': 'branch', 502 'extraOptions': [{ 503 'key': 'additional-files-filter', 504 'values': [f'{target}.zip'], 505 }], 506 'command': '/tf/command', 507 'extraBuildTargets': [ 508 'extra_build_target', 509 ], 510 }, 511 ], 512 } 513 514 def run_packaging_commands(self, build_plan: build_test_suites.BuildPlan): 515 return [ 516 packaging_command_getter() 517 for packaging_command_getter in build_plan.packaging_commands_getters 518 ] 519 520 521def wait_until( 522 condition_function: Callable[[], bool], 523 timeout_secs: float = 3.0, 524 polling_interval_secs: float = 0.1, 525): 526 """Waits until a condition function returns True.""" 527 528 start_time_secs = time.time() 529 530 while not condition_function(): 531 if time.time() - start_time_secs > timeout_secs: 532 raise TimeoutError( 533 f'Condition not met within timeout: {timeout_secs} seconds' 534 ) 535 536 time.sleep(polling_interval_secs) 537 538 539def read_file_contents(file: pathlib.Path) -> str: 540 with open(file, 'r') as f: 541 return f.read() 542 543 544def read_eventual_file_contents(file: pathlib.Path) -> str: 545 wait_until(lambda: file.is_file() and file.stat().st_size > 0) 546 return read_file_contents(file) 547 548 549if __name__ == '__main__': 550 ci_test_lib.main() 551