blob: 215d992cf00a51defa7bc3fce025059344ffb2b2 [file] [log] [blame]
Zhuoyao Zhangcc44d2e2024-03-26 22:50:12 +00001# 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
15import dataclasses
Zhuoyao Zhangdb666bc2024-04-30 00:28:34 +000016import glob
Zhuoyao Zhangcc44d2e2024-03-26 22:50:12 +000017from importlib import resources
18import logging
19import os
20from pathlib import Path
21import re
Zhuoyao Zhangdb666bc2024-04-30 00:28:34 +000022import shutil
Zhuoyao Zhangcc44d2e2024-03-26 22:50:12 +000023import signal
24import stat
25import subprocess
Zhuoyao Zhangdb666bc2024-04-30 00:28:34 +000026import sys
Zhuoyao Zhangcc44d2e2024-03-26 22:50:12 +000027import tempfile
28import textwrap
29import time
30import unittest
31import zipfile
Zhuoyao Zhangcc44d2e2024-03-26 22:50:12 +000032
33EXII_RETURN_CODE = 0
34INTERRUPTED_RETURN_CODE = 130
35
36
37class RunToolWithLoggingTest(unittest.TestCase):
38
39 @classmethod
40 def setUpClass(cls):
41 super().setUpClass()
42 # Configure to print logging to stdout.
43 logging.basicConfig(filename=None, level=logging.DEBUG)
44 console = logging.StreamHandler(sys.stdout)
Zhuoyao Zhangdb666bc2024-04-30 00:28:34 +000045 logging.getLogger("").addHandler(console)
Zhuoyao Zhangcc44d2e2024-03-26 22:50:12 +000046
47 def setUp(self):
48 super().setUp()
49 self.working_dir = tempfile.TemporaryDirectory()
50 # Run all the tests from working_dir which is our temp Android build top.
51 os.chdir(self.working_dir.name)
52 # Extract envsetup.zip which contains the envsetup.sh and other dependent
53 # scripts required to set up the build environments.
Zhuoyao Zhangdb666bc2024-04-30 00:28:34 +000054 with resources.files("testdata").joinpath("envsetup.zip").open("rb") as p:
Zhuoyao Zhangcc44d2e2024-03-26 22:50:12 +000055 with zipfile.ZipFile(p, "r") as zip_f:
56 zip_f.extractall()
57
58 def tearDown(self):
59 self.working_dir.cleanup()
60 super().tearDown()
61
62 def test_does_not_log_when_logging_disabled(self):
63 test_tool = TestScript.create(self.working_dir)
64 test_logger = TestScript.create(self.working_dir)
65
66 self._run_script_and_wait(f"""
67 ANDROID_ENABLE_TOOL_LOGGING=false
68 ANDROID_TOOL_LOGGER="{test_logger.executable}"
69 run_tool_with_logging "FAKE_TOOL" {test_tool.executable} arg1 arg2
70 """)
71
72 test_tool.assert_called_once_with_args("arg1 arg2")
73 test_logger.assert_not_called()
74
75 def test_does_not_log_when_logger_var_unset(self):
76 test_tool = TestScript.create(self.working_dir)
77 test_logger = TestScript.create(self.working_dir)
78
79 self._run_script_and_wait(f"""
80 unset ANDROID_ENABLE_TOOL_LOGGING
81 ANDROID_TOOL_LOGGER="{test_logger.executable}"
82 run_tool_with_logging "FAKE_TOOL" {test_tool.executable} arg1 arg2
83 """)
84
85 test_tool.assert_called_once_with_args("arg1 arg2")
86 test_logger.assert_not_called()
87
88 def test_does_not_log_when_logger_var_empty(self):
89 test_tool = TestScript.create(self.working_dir)
90
91 self._run_script_and_wait(f"""
92 ANDROID_ENABLE_TOOL_LOGGING=true
93 ANDROID_TOOL_LOGGER=""
94 run_tool_with_logging "FAKE_TOOL" {test_tool.executable} arg1 arg2
95 """)
96
97 test_tool.assert_called_once_with_args("arg1 arg2")
98
99 def test_does_not_log_with_logger_unset(self):
100 test_tool = TestScript.create(self.working_dir)
101
102 self._run_script_and_wait(f"""
103 ANDROID_ENABLE_TOOL_LOGGING=true
104 unset ANDROID_TOOL_LOGGER
105 run_tool_with_logging "FAKE_TOOL" {test_tool.executable} arg1 arg2
106 """)
107
108 test_tool.assert_called_once_with_args("arg1 arg2")
109
110 def test_log_success_with_logger_enabled(self):
111 test_tool = TestScript.create(self.working_dir)
112 test_logger = TestScript.create(self.working_dir)
113
114 self._run_script_and_wait(f"""
115 ANDROID_ENABLE_TOOL_LOGGING=true
116 ANDROID_TOOL_LOGGER="{test_logger.executable}"
117 run_tool_with_logging "FAKE_TOOL" {test_tool.executable} arg1 arg2
118 """)
119
120 test_tool.assert_called_once_with_args("arg1 arg2")
121 expected_logger_args = (
122 "--tool_tag FAKE_TOOL --start_timestamp \d+\.\d+ --end_timestamp"
Zhuoyao Zhangdb666bc2024-04-30 00:28:34 +0000123 " \d+\.\d+ --tool_args arg1 arg2 --exit_code 0"
Zhuoyao Zhangcc44d2e2024-03-26 22:50:12 +0000124 )
125 test_logger.assert_called_once_with_args(expected_logger_args)
126
127 def test_run_tool_output_is_same_with_and_without_logging(self):
128 test_tool = TestScript.create(self.working_dir, "echo 'tool called'")
129 test_logger = TestScript.create(self.working_dir)
130
131 run_tool_with_logging_stdout, run_tool_with_logging_stderr = (
132 self._run_script_and_wait(f"""
133 ANDROID_ENABLE_TOOL_LOGGING=true
134 ANDROID_TOOL_LOGGER="{test_logger.executable}"
135 run_tool_with_logging "FAKE_TOOL" {test_tool.executable} arg1 arg2
136 """)
137 )
138
139 run_tool_without_logging_stdout, run_tool_without_logging_stderr = (
140 self._run_script_and_wait(f"""
141 ANDROID_ENABLE_TOOL_LOGGING=true
142 ANDROID_TOOL_LOGGER="{test_logger.executable}"
143 {test_tool.executable} arg1 arg2
144 """)
145 )
146
147 self.assertEqual(
148 run_tool_with_logging_stdout, run_tool_without_logging_stdout
149 )
150 self.assertEqual(
151 run_tool_with_logging_stderr, run_tool_without_logging_stderr
152 )
153
154 def test_logger_output_is_suppressed(self):
155 test_tool = TestScript.create(self.working_dir)
156 test_logger = TestScript.create(self.working_dir, "echo 'logger called'")
157
158 run_tool_with_logging_output, _ = self._run_script_and_wait(f"""
159 ANDROID_ENABLE_TOOL_LOGGING=true
160 ANDROID_TOOL_LOGGER="{test_logger.executable}"
161 run_tool_with_logging "FAKE_TOOL" {test_tool.executable} arg1 arg2
162 """)
163
164 self.assertNotIn("logger called", run_tool_with_logging_output)
165
166 def test_logger_error_is_suppressed(self):
167 test_tool = TestScript.create(self.working_dir)
168 test_logger = TestScript.create(
169 self.working_dir, "echo 'logger failed' > /dev/stderr; exit 1"
170 )
171
172 _, err = self._run_script_and_wait(f"""
173 ANDROID_ENABLE_TOOL_LOGGING=true
174 ANDROID_TOOL_LOGGER="{test_logger.executable}"
175 run_tool_with_logging "FAKE_TOOL" {test_tool.executable} arg1 arg2
176 """)
177
178 self.assertNotIn("logger failed", err)
179
180 def test_log_success_when_tool_interrupted(self):
181 test_tool = TestScript.create(self.working_dir, script_body="sleep 100")
182 test_logger = TestScript.create(self.working_dir)
183
184 process = self._run_script_in_build_env(f"""
185 ANDROID_ENABLE_TOOL_LOGGING=true
186 ANDROID_TOOL_LOGGER="{test_logger.executable}"
187 run_tool_with_logging "FAKE_TOOL" {test_tool.executable} arg1 arg2
188 """)
189
190 pgid = os.getpgid(process.pid)
191 # Give sometime for the subprocess to start.
192 time.sleep(1)
193 # Kill the subprocess and any processes created in the same group.
194 os.killpg(pgid, signal.SIGINT)
195
196 returncode, _, _ = self._wait_for_process(process)
197 self.assertEqual(returncode, INTERRUPTED_RETURN_CODE)
198
199 expected_logger_args = (
200 "--tool_tag FAKE_TOOL --start_timestamp \d+\.\d+ --end_timestamp"
Zhuoyao Zhangdb666bc2024-04-30 00:28:34 +0000201 " \d+\.\d+ --tool_args arg1 arg2 --exit_code 130"
Zhuoyao Zhangcc44d2e2024-03-26 22:50:12 +0000202 )
203 test_logger.assert_called_once_with_args(expected_logger_args)
204
205 def test_logger_can_be_toggled_on(self):
206 test_tool = TestScript.create(self.working_dir)
207 test_logger = TestScript.create(self.working_dir)
208
209 self._run_script_and_wait(f"""
210 ANDROID_ENABLE_TOOL_LOGGING=false
211 ANDROID_TOOL_LOGGER="{test_logger.executable}"
212 ANDROID_ENABLE_TOOL_LOGGING=true
213 run_tool_with_logging "FAKE_TOOL" {test_tool.executable} arg1 arg2
214 """)
215
216 test_logger.assert_called_with_times(1)
217
218 def test_logger_can_be_toggled_off(self):
219 test_tool = TestScript.create(self.working_dir)
220 test_logger = TestScript.create(self.working_dir)
221
222 self._run_script_and_wait(f"""
223 ANDROID_ENABLE_TOOL_LOGGING=true
224 ANDROID_TOOL_LOGGER="{test_logger.executable}"
225 ANDROID_ENABLE_TOOL_LOGGING=false
226 run_tool_with_logging "FAKE_TOOL" {test_tool.executable} arg1 arg2
227 """)
228
229 test_logger.assert_not_called()
230
Zhuoyao Zhangdb666bc2024-04-30 00:28:34 +0000231 def test_integration_tool_event_logger_dry_run(self):
232 test_tool = TestScript.create(self.working_dir)
233 logger_path = self._import_logger()
234
235 self._run_script_and_wait(f"""
236 TMPDIR="{self.working_dir.name}"
237 ANDROID_ENABLE_TOOL_LOGGING=true
238 ANDROID_TOOL_LOGGER="{logger_path}"
239 ANDROID_TOOL_LOGGER_EXTRA_ARGS="--dry_run"
240 run_tool_with_logging "FAKE_TOOL" {test_tool.executable} arg1 arg2
241 """)
242
243 self._assert_logger_dry_run()
244
245 def _import_logger(self) -> Path:
246 logger = "tool_event_logger"
247 logger_path = Path(self.working_dir.name).joinpath(logger)
248 with resources.as_file(resources.files("testdata").joinpath(logger)) as p:
249 shutil.copy(p, logger_path)
250 Path.chmod(logger_path, 0o755)
251 return logger_path
252
253 def _assert_logger_dry_run(self):
254 log_files = glob.glob(self.working_dir.name + "/tool_event_logger_*/*.log")
255 self.assertEqual(len(log_files), 1)
256
257 with open(log_files[0], "r") as f:
258 lines = f.readlines()
259 self.assertEqual(len(lines), 1)
260 self.assertIn("dry run", lines[0])
261
Zhuoyao Zhangcc44d2e2024-03-26 22:50:12 +0000262 def _create_build_env_script(self) -> str:
263 return f"""
264 source {Path(self.working_dir.name).joinpath("build/make/envsetup.sh")}
265 """
266
267 def _run_script_and_wait(self, test_script: str) -> tuple[str, str]:
268 process = self._run_script_in_build_env(test_script)
269 returncode, out, err = self._wait_for_process(process)
270 logging.debug("script stdout: %s", out)
271 logging.debug("script stderr: %s", err)
272 self.assertEqual(returncode, EXII_RETURN_CODE)
273 return out, err
274
275 def _run_script_in_build_env(self, test_script: str) -> subprocess.Popen:
276 setup_build_env_script = self._create_build_env_script()
277 return subprocess.Popen(
278 setup_build_env_script + test_script,
279 shell=True,
280 stdout=subprocess.PIPE,
281 stderr=subprocess.PIPE,
282 text=True,
283 start_new_session=True,
Zhuoyao Zhangdb666bc2024-04-30 00:28:34 +0000284 executable="/bin/bash",
Zhuoyao Zhangcc44d2e2024-03-26 22:50:12 +0000285 )
286
287 def _wait_for_process(
288 self, process: subprocess.Popen
289 ) -> tuple[int, str, str]:
290 pgid = os.getpgid(process.pid)
291 out, err = process.communicate()
292 # Wait for all process in the same group to complete since the logger runs
293 # as a separate detached process.
294 self._wait_for_process_group(pgid)
295 return (process.returncode, out, err)
296
297 def _wait_for_process_group(self, pgid: int, timeout: int = 5):
298 """Waits for all subprocesses within the process group to complete."""
299 start_time = time.time()
300 while True:
301 if time.time() - start_time > timeout:
302 raise TimeoutError(
303 f"Process group did not complete after {timeout} seconds"
304 )
305 for pid in os.listdir("/proc"):
306 if pid.isdigit():
307 try:
308 if os.getpgid(int(pid)) == pgid:
309 time.sleep(0.1)
310 break
311 except (FileNotFoundError, PermissionError, ProcessLookupError):
312 pass
313 else:
314 # All processes have completed.
315 break
316
317
318@dataclasses.dataclass
319class TestScript:
320 executable: Path
321 output_file: Path
322
323 def create(temp_dir: Path, script_body: str = ""):
324 with tempfile.NamedTemporaryFile(dir=temp_dir.name, delete=False) as f:
325 output_file = f.name
326
327 with tempfile.NamedTemporaryFile(dir=temp_dir.name, delete=False) as f:
328 executable = f.name
329 executable_contents = textwrap.dedent(f"""
330 #!/bin/bash
331
332 echo "${{@}}" >> {output_file}
333 {script_body}
334 """)
335 f.write(executable_contents.encode("utf-8"))
336
Zhuoyao Zhangdb666bc2024-04-30 00:28:34 +0000337 Path.chmod(f.name, os.stat(f.name).st_mode | stat.S_IEXEC)
Zhuoyao Zhangcc44d2e2024-03-26 22:50:12 +0000338
339 return TestScript(executable, output_file)
340
341 def assert_called_with_times(self, expected_call_times: int):
342 lines = self._read_contents_from_output_file()
343 assert len(lines) == expected_call_times, (
344 f"Expect to call {expected_call_times} times, but actually called"
345 f" {len(lines)} times."
346 )
347
348 def assert_called_with_args(self, expected_args: str):
349 lines = self._read_contents_from_output_file()
350 assert len(lines) > 0
351 assert re.search(expected_args, lines[0]), (
352 f"Expect to call with args {expected_args}, but actually called with"
353 f" args {lines[0]}."
354 )
355
356 def assert_not_called(self):
357 self.assert_called_with_times(0)
358
359 def assert_called_once_with_args(self, expected_args: str):
360 self.assert_called_with_times(1)
361 self.assert_called_with_args(expected_args)
362
363 def _read_contents_from_output_file(self) -> list[str]:
364 with open(self.output_file, "r") as f:
365 return f.readlines()
366
367
368if __name__ == "__main__":
369 unittest.main()