blob: 08a79a329458fd2d1390e2b354d72b9f842468ad [file] [log] [blame]
Luca Farsidb136442024-03-26 10:55:21 -07001# 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
17from importlib import resources
18import multiprocessing
19import os
20import pathlib
21import shutil
22import signal
23import stat
24import subprocess
25import sys
26import tempfile
27import textwrap
28import time
29from typing import Callable
30from unittest import mock
31import build_test_suites
32import ci_test_lib
33from pyfakefs import fake_filesystem_unittest
34
35
36class BuildTestSuitesTest(fake_filesystem_unittest.TestCase):
37
38 def setUp(self):
39 self.setUpPyfakefs()
40
41 os_environ_patcher = mock.patch.dict('os.environ', {})
42 self.addCleanup(os_environ_patcher.stop)
43 self.mock_os_environ = os_environ_patcher.start()
44
45 subprocess_run_patcher = mock.patch('subprocess.run')
46 self.addCleanup(subprocess_run_patcher.stop)
47 self.mock_subprocess_run = subprocess_run_patcher.start()
48
49 self._setup_working_build_env()
50
51 def test_missing_target_release_env_var_raises(self):
52 del os.environ['TARGET_RELEASE']
53
54 with self.assert_raises_word(build_test_suites.Error, 'TARGET_RELEASE'):
55 build_test_suites.main([])
56
57 def test_missing_target_product_env_var_raises(self):
58 del os.environ['TARGET_PRODUCT']
59
60 with self.assert_raises_word(build_test_suites.Error, 'TARGET_PRODUCT'):
61 build_test_suites.main([])
62
63 def test_missing_top_env_var_raises(self):
64 del os.environ['TOP']
65
66 with self.assert_raises_word(build_test_suites.Error, 'TOP'):
67 build_test_suites.main([])
68
69 def test_invalid_arg_raises(self):
70 invalid_args = ['--invalid_arg']
71
72 with self.assertRaisesRegex(SystemExit, '2'):
73 build_test_suites.main(invalid_args)
74
75 def test_build_failure_returns(self):
76 self.mock_subprocess_run.side_effect = subprocess.CalledProcessError(
77 42, None
78 )
79
80 with self.assertRaisesRegex(SystemExit, '42'):
81 build_test_suites.main([])
82
83 def test_build_success_returns(self):
84 with self.assertRaisesRegex(SystemExit, '0'):
85 build_test_suites.main([])
86
87 def assert_raises_word(self, cls, word):
88 return self.assertRaisesRegex(build_test_suites.Error, rf'\b{word}\b')
89
90 def _setup_working_build_env(self):
91 self.fake_top = pathlib.Path('/fake/top')
92 self.fake_top.mkdir(parents=True)
93
94 self.soong_ui_dir = self.fake_top.joinpath('build/soong')
95 self.soong_ui_dir.mkdir(parents=True, exist_ok=True)
96
97 self.soong_ui = self.soong_ui_dir.joinpath('soong_ui.bash')
98 self.soong_ui.touch()
99
100 self.mock_os_environ.update({
101 'TARGET_RELEASE': 'release',
102 'TARGET_PRODUCT': 'product',
103 'TOP': str(self.fake_top),
104 })
105
106 self.mock_subprocess_run.return_value = 0
107
108
109class RunCommandIntegrationTest(ci_test_lib.TestCase):
110
111 def setUp(self):
112 self.temp_dir = ci_test_lib.TestTemporaryDirectory.create(self)
113
114 # Copy the Python executable from 'non-code' resources and make it
115 # executable for use by tests that launch a subprocess. Note that we don't
116 # use Python's native `sys.executable` property since that is not set when
117 # running via the embedded launcher.
118 base_name = 'py3-cmd'
119 dest_file = self.temp_dir.joinpath(base_name)
120 with resources.as_file(
121 resources.files('testdata').joinpath(base_name)
122 ) as p:
123 shutil.copy(p, dest_file)
124 dest_file.chmod(dest_file.stat().st_mode | stat.S_IEXEC)
125 self.python_executable = dest_file
126
127 self._managed_processes = []
128
129 def tearDown(self):
130 self._terminate_managed_processes()
131
132 def test_raises_on_nonzero_exit(self):
133 with self.assertRaises(Exception):
134 build_test_suites.run_command([
135 self.python_executable,
136 '-c',
137 textwrap.dedent(f"""\
138 import sys
139 sys.exit(1)
140 """),
141 ])
142
143 def test_streams_stdout(self):
144
145 def run_slow_command(stdout_file, marker):
146 with open(stdout_file, 'w') as f:
147 build_test_suites.run_command(
148 [
149 self.python_executable,
150 '-c',
151 textwrap.dedent(f"""\
152 import time
153
154 print('{marker}', end='', flush=True)
155
156 # Keep process alive until we check stdout.
157 time.sleep(10)
158 """),
159 ],
160 stdout=f,
161 )
162
163 marker = 'Spinach'
164 stdout_file = self.temp_dir.joinpath('stdout.txt')
165
166 p = self.start_process(target=run_slow_command, args=[stdout_file, marker])
167
168 self.assert_file_eventually_contains(stdout_file, marker)
169
170 def test_propagates_interruptions(self):
171
172 def run(pid_file):
173 build_test_suites.run_command([
174 self.python_executable,
175 '-c',
176 textwrap.dedent(f"""\
177 import os
178 import pathlib
179 import time
180
181 pathlib.Path('{pid_file}').write_text(str(os.getpid()))
182
183 # Keep the process alive for us to explicitly interrupt it.
184 time.sleep(10)
185 """),
186 ])
187
188 pid_file = self.temp_dir.joinpath('pid.txt')
189 p = self.start_process(target=run, args=[pid_file])
190 subprocess_pid = int(read_eventual_file_contents(pid_file))
191
192 os.kill(p.pid, signal.SIGINT)
193 p.join()
194
195 self.assert_process_eventually_dies(p.pid)
196 self.assert_process_eventually_dies(subprocess_pid)
197
198 def start_process(self, *args, **kwargs) -> multiprocessing.Process:
199 p = multiprocessing.Process(*args, **kwargs)
200 self._managed_processes.append(p)
201 p.start()
202 return p
203
204 def assert_process_eventually_dies(self, pid: int):
205 try:
206 wait_until(lambda: not ci_test_lib.process_alive(pid))
207 except TimeoutError as e:
208 self.fail(f'Process {pid} did not die after a while: {e}')
209
210 def assert_file_eventually_contains(self, file: pathlib.Path, substring: str):
211 wait_until(lambda: file.is_file() and file.stat().st_size > 0)
212 self.assertIn(substring, read_file_contents(file))
213
214 def _terminate_managed_processes(self):
215 for p in self._managed_processes:
216 if not p.is_alive():
217 continue
218
219 # We terminate the process with `SIGINT` since using `terminate` or
220 # `SIGKILL` doesn't kill any grandchild processes and we don't have
221 # `psutil` available to easily query all children.
222 os.kill(p.pid, signal.SIGINT)
223
224
225def wait_until(
226 condition_function: Callable[[], bool],
227 timeout_secs: float = 3.0,
228 polling_interval_secs: float = 0.1,
229):
230 """Waits until a condition function returns True."""
231
232 start_time_secs = time.time()
233
234 while not condition_function():
235 if time.time() - start_time_secs > timeout_secs:
236 raise TimeoutError(
237 f'Condition not met within timeout: {timeout_secs} seconds'
238 )
239
240 time.sleep(polling_interval_secs)
241
242
243def read_file_contents(file: pathlib.Path) -> str:
244 with open(file, 'r') as f:
245 return f.read()
246
247
248def read_eventual_file_contents(file: pathlib.Path) -> str:
249 wait_until(lambda: file.is_file() and file.stat().st_size > 0)
250 return read_file_contents(file)
251
252
253if __name__ == '__main__':
254 ci_test_lib.main()