• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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