xref: /aosp_15_r20/build/make/ci/optimized_targets_test.py (revision 9e94795a3d4ef5c1d47486f9a02bb378756cea8a)
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 optimized_targets.py"""
16
17import json
18import logging
19import os
20import pathlib
21import re
22import subprocess
23import textwrap
24import unittest
25from unittest import mock
26from build_context import BuildContext
27import optimized_targets
28from pyfakefs import fake_filesystem_unittest
29
30
31class GeneralTestsOptimizerTest(fake_filesystem_unittest.TestCase):
32
33  def setUp(self):
34    self.setUpPyfakefs()
35
36    os_environ_patcher = mock.patch.dict('os.environ', {})
37    self.addCleanup(os_environ_patcher.stop)
38    self.mock_os_environ = os_environ_patcher.start()
39
40    self._setup_working_build_env()
41    self._write_change_info_file()
42    test_mapping_dir = pathlib.Path('/project/path/file/path')
43    test_mapping_dir.mkdir(parents=True)
44    self._write_test_mapping_file()
45
46  def _setup_working_build_env(self):
47    self.change_info_file = pathlib.Path('/tmp/change_info')
48    self._write_soong_ui_file()
49    self._host_out_testcases = pathlib.Path('/tmp/top/host_out_testcases')
50    self._host_out_testcases.mkdir(parents=True)
51    self._target_out_testcases = pathlib.Path('/tmp/top/target_out_testcases')
52    self._target_out_testcases.mkdir(parents=True)
53    self._product_out = pathlib.Path('/tmp/top/product_out')
54    self._product_out.mkdir(parents=True)
55    self._soong_host_out = pathlib.Path('/tmp/top/soong_host_out')
56    self._soong_host_out.mkdir(parents=True)
57    self._host_out = pathlib.Path('/tmp/top/host_out')
58    self._host_out.mkdir(parents=True)
59
60    self._dist_dir = pathlib.Path('/tmp/top/out/dist')
61    self._dist_dir.mkdir(parents=True)
62
63    self.mock_os_environ.update({
64        'CHANGE_INFO': str(self.change_info_file),
65        'TOP': '/tmp/top',
66        'DIST_DIR': '/tmp/top/out/dist',
67    })
68
69  def _write_soong_ui_file(self):
70    soong_path = pathlib.Path('/tmp/top/build/soong')
71    soong_path.mkdir(parents=True)
72    with open(os.path.join(soong_path, 'soong_ui.bash'), 'w') as f:
73      f.write("""
74              #/bin/bash
75              echo HOST_OUT_TESTCASES='/tmp/top/host_out_testcases'
76              echo TARGET_OUT_TESTCASES='/tmp/top/target_out_testcases'
77              echo PRODUCT_OUT='/tmp/top/product_out'
78              echo SOONG_HOST_OUT='/tmp/top/soong_host_out'
79              echo HOST_OUT='/tmp/top/host_out'
80              """)
81    os.chmod(os.path.join(soong_path, 'soong_ui.bash'), 0o666)
82
83  def _write_change_info_file(self):
84    change_info_contents = {
85        'changes': [{
86            'projectPath': '/project/path',
87            'revisions': [{
88                'fileInfos': [{
89                    'path': 'file/path/file_name',
90                }],
91            }],
92        }]
93    }
94
95    with open(self.change_info_file, 'w') as f:
96      json.dump(change_info_contents, f)
97
98  def _write_test_mapping_file(self):
99    test_mapping_contents = {
100        'test-mapping-group': [
101            {
102                'name': 'test_mapping_module',
103            },
104        ],
105    }
106
107    with open('/project/path/file/path/TEST_MAPPING', 'w') as f:
108      json.dump(test_mapping_contents, f)
109
110  def test_general_tests_optimized(self):
111    optimizer = self._create_general_tests_optimizer()
112
113    build_targets = optimizer.get_build_targets()
114
115    expected_build_targets = set(
116        optimized_targets.GeneralTestsOptimizer._REQUIRED_MODULES
117    )
118    expected_build_targets.add('test_mapping_module')
119
120    self.assertSetEqual(build_targets, expected_build_targets)
121
122  def test_no_change_info_no_optimization(self):
123    del os.environ['CHANGE_INFO']
124
125    optimizer = self._create_general_tests_optimizer()
126
127    build_targets = optimizer.get_build_targets()
128
129    self.assertSetEqual(build_targets, {'general-tests'})
130
131  def test_mapping_groups_unused_module_not_built(self):
132    test_context = self._create_test_context()
133    test_context['testInfos'][0]['extraOptions'] = [
134        {
135            'key': 'additional-files-filter',
136            'values': ['general-tests.zip'],
137        },
138        {
139            'key': 'test-mapping-test-group',
140            'values': ['unused-test-mapping-group'],
141        },
142    ]
143    optimizer = self._create_general_tests_optimizer(
144        build_context=self._create_build_context(test_context=test_context)
145    )
146
147    build_targets = optimizer.get_build_targets()
148
149    expected_build_targets = set(
150        optimized_targets.GeneralTestsOptimizer._REQUIRED_MODULES
151    )
152    self.assertSetEqual(build_targets, expected_build_targets)
153
154  def test_general_tests_used_by_non_test_mapping_test_no_optimization(self):
155    test_context = self._create_test_context()
156    test_context['testInfos'][0]['extraOptions'] = [{
157        'key': 'additional-files-filter',
158        'values': ['general-tests.zip'],
159    }]
160    optimizer = self._create_general_tests_optimizer(
161        build_context=self._create_build_context(test_context=test_context)
162    )
163
164    build_targets = optimizer.get_build_targets()
165
166    self.assertSetEqual(build_targets, {'general-tests'})
167
168  def test_malformed_change_info_raises(self):
169    with open(self.change_info_file, 'w') as f:
170      f.write('not change info')
171
172    optimizer = self._create_general_tests_optimizer()
173
174    with self.assertRaises(json.decoder.JSONDecodeError):
175      build_targets = optimizer.get_build_targets()
176
177  def test_malformed_test_mapping_raises(self):
178    with open('/project/path/file/path/TEST_MAPPING', 'w') as f:
179      f.write('not test mapping')
180
181    optimizer = self._create_general_tests_optimizer()
182
183    with self.assertRaises(json.decoder.JSONDecodeError):
184      build_targets = optimizer.get_build_targets()
185
186  @mock.patch('subprocess.run')
187  def test_packaging_outputs_success(self, subprocess_run):
188    subprocess_run.return_value = self._get_soong_vars_output()
189    optimizer = self._create_general_tests_optimizer()
190    self._set_up_build_outputs(['test_mapping_module'])
191
192    targets = optimizer.get_build_targets()
193    package_commands = optimizer.get_package_outputs_commands()
194
195    self._verify_soong_zip_commands(package_commands, ['test_mapping_module'])
196
197  @mock.patch('subprocess.run')
198  def test_get_soong_dumpvars_fails_raises(self, subprocess_run):
199    subprocess_run.return_value = self._get_soong_vars_output(return_code=-1)
200    optimizer = self._create_general_tests_optimizer()
201    self._set_up_build_outputs(['test_mapping_module'])
202
203    targets = optimizer.get_build_targets()
204
205    with self.assertRaisesRegex(RuntimeError, 'Soong dumpvars failed!'):
206      package_commands = optimizer.get_package_outputs_commands()
207
208  @mock.patch('subprocess.run')
209  def test_get_soong_dumpvars_bad_output_raises(self, subprocess_run):
210    subprocess_run.return_value = self._get_soong_vars_output(
211        stdout='This output is bad'
212    )
213    optimizer = self._create_general_tests_optimizer()
214    self._set_up_build_outputs(['test_mapping_module'])
215
216    targets = optimizer.get_build_targets()
217
218    with self.assertRaisesRegex(
219        RuntimeError, 'Error parsing soong dumpvars output'
220    ):
221      package_commands = optimizer.get_package_outputs_commands()
222
223  def _create_general_tests_optimizer(self, build_context: BuildContext = None):
224    if not build_context:
225      build_context = self._create_build_context()
226    return optimized_targets.GeneralTestsOptimizer(
227        'general-tests', build_context, None
228    )
229
230  def _create_build_context(
231      self,
232      general_tests_optimized: bool = True,
233      test_context: dict[str, any] = None,
234  ) -> BuildContext:
235    if not test_context:
236      test_context = self._create_test_context()
237    build_context_dict = {}
238    build_context_dict['enabledBuildFeatures'] = [{'name': 'optimized_build'}]
239    if general_tests_optimized:
240      build_context_dict['enabledBuildFeatures'].append(
241          {'name': 'general_tests_optimized'}
242      )
243    build_context_dict['testContext'] = test_context
244    return BuildContext(build_context_dict)
245
246  def _create_test_context(self):
247    return {
248        'testInfos': [
249            {
250                'name': 'atp_test',
251                'target': 'test_target',
252                'branch': 'branch',
253                'extraOptions': [
254                    {
255                        'key': 'additional-files-filter',
256                        'values': ['general-tests.zip'],
257                    },
258                    {
259                        'key': 'test-mapping-test-group',
260                        'values': ['test-mapping-group'],
261                    },
262                ],
263                'command': '/tf/command',
264                'extraBuildTargets': [
265                    'extra_build_target',
266                ],
267            },
268        ],
269    }
270
271  def _get_soong_vars_output(
272      self, return_code: int = 0, stdout: str = ''
273  ) -> subprocess.CompletedProcess:
274    return_value = subprocess.CompletedProcess(args=[], returncode=return_code)
275    if not stdout:
276      stdout = textwrap.dedent(f"""\
277                               HOST_OUT_TESTCASES='{self._host_out_testcases}'
278                               TARGET_OUT_TESTCASES='{self._target_out_testcases}'
279                               PRODUCT_OUT='{self._product_out}'
280                               SOONG_HOST_OUT='{self._soong_host_out}'
281                               HOST_OUT='{self._host_out}'""")
282
283    return_value.stdout = stdout
284    return return_value
285
286  def _set_up_build_outputs(self, targets: list[str]):
287    for target in targets:
288      host_dir = self._host_out_testcases / target
289      host_dir.mkdir()
290      (host_dir / f'{target}.config').touch()
291      (host_dir / f'test_file').touch()
292
293      target_dir = self._target_out_testcases / target
294      target_dir.mkdir()
295      (target_dir / f'{target}.config').touch()
296      (target_dir / f'test_file').touch()
297
298  def _verify_soong_zip_commands(self, commands: list[str], targets: list[str]):
299    """Verify the structure of the zip commands.
300
301    Zip commands have to start with the soong_zip binary path, then are followed
302    by a couple of options and the name of the file being zipped. Depending on
303    which zip we are creating look for a few essential items being added in
304    those zips.
305
306    Args:
307      commands: list of command lists
308      targets: list of targets expected to be in general-tests.zip
309    """
310    for command in commands:
311      self.assertEqual(
312          '/tmp/top/prebuilts/build-tools/linux-x86/bin/soong_zip',
313          command[0],
314      )
315      self.assertEqual('-d', command[1])
316      self.assertEqual('-o', command[2])
317      match (command[3]):
318        case '/tmp/top/out/dist/general-tests_configs.zip':
319          self.assertIn(f'{self._host_out}/host_general-tests_list', command)
320          self.assertIn(
321              f'{self._product_out}/target_general-tests_list', command
322          )
323          return
324        case '/tmp/top/out/dist/general-tests_list.zip':
325          self.assertIn('-f', command)
326          self.assertIn(f'{self._host_out}/general-tests_list', command)
327          return
328        case '/tmp/top/out/dist/general-tests.zip':
329          for target in targets:
330            self.assertIn(f'{self._host_out_testcases}/{target}', command)
331            self.assertIn(f'{self._target_out_testcases}/{target}', command)
332          self.assertIn(
333              f'{self._soong_host_out}/framework/cts-tradefed.jar', command
334          )
335          self.assertIn(
336              f'{self._soong_host_out}/framework/compatibility-host-util.jar',
337              command,
338          )
339          self.assertIn(
340              f'{self._soong_host_out}/framework/vts-tradefed.jar', command
341          )
342          return
343        case _:
344          self.fail(f'malformed command: {command}')
345
346
347if __name__ == '__main__':
348  # Setup logging to be silent so unit tests can pass through TF.
349  logging.disable(logging.ERROR)
350  unittest.main()
351