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