xref: /aosp_15_r20/external/toolchain-utils/run_tests_for.py (revision 760c253c1ed00ce9abd48f8546f08516e57485fe)
1*760c253cSXin Li#!/usr/bin/env python3
2*760c253cSXin Li# Copyright 2019 The ChromiumOS Authors
3*760c253cSXin Li# Use of this source code is governed by a BSD-style license that can be
4*760c253cSXin Li# found in the LICENSE file.
5*760c253cSXin Li
6*760c253cSXin Li"""Runs tests for the given input files.
7*760c253cSXin Li
8*760c253cSXin LiTries its best to autodetect all tests based on path name without being *too*
9*760c253cSXin Liaggressive.
10*760c253cSXin Li
11*760c253cSXin LiIn short, there's a small set of directories in which, if you make any change,
12*760c253cSXin Liall of the tests in those directories get run. Additionally, if you change a
13*760c253cSXin Lipython file named foo, it'll run foo_test.py or foo_unittest.py if either of
14*760c253cSXin Lithose exist.
15*760c253cSXin Li
16*760c253cSXin LiAll tests are run in parallel.
17*760c253cSXin Li"""
18*760c253cSXin Li
19*760c253cSXin Li# NOTE: An alternative mentioned on the initial CL for this
20*760c253cSXin Li# https://chromium-review.googlesource.com/c/chromiumos/third_party/toolchain-utils/+/1516414
21*760c253cSXin Li# is pytest. It looks like that brings some complexity (and makes use outside
22*760c253cSXin Li# of the chroot a bit more obnoxious?), but might be worth exploring if this
23*760c253cSXin Li# starts to grow quite complex on its own.
24*760c253cSXin Li
25*760c253cSXin Li
26*760c253cSXin Liimport argparse
27*760c253cSXin Liimport collections
28*760c253cSXin Liimport multiprocessing.pool
29*760c253cSXin Liimport os
30*760c253cSXin Liimport shlex
31*760c253cSXin Liimport signal
32*760c253cSXin Liimport subprocess
33*760c253cSXin Liimport sys
34*760c253cSXin Lifrom typing import Optional, Tuple
35*760c253cSXin Li
36*760c253cSXin Li
37*760c253cSXin LiTestSpec = collections.namedtuple("TestSpec", ["directory", "command"])
38*760c253cSXin Li
39*760c253cSXin Li# List of python scripts that are not test with relative path to
40*760c253cSXin Li# toolchain-utils.
41*760c253cSXin Linon_test_py_files = {
42*760c253cSXin Li    "debug_info_test/debug_info_test.py",
43*760c253cSXin Li}
44*760c253cSXin Li
45*760c253cSXin Li
46*760c253cSXin Lidef _make_relative_to_toolchain_utils(toolchain_utils, path):
47*760c253cSXin Li    """Cleans & makes a path relative to toolchain_utils.
48*760c253cSXin Li
49*760c253cSXin Li    Raises if that path isn't under toolchain_utils.
50*760c253cSXin Li    """
51*760c253cSXin Li    # abspath has the nice property that it removes any markers like './'.
52*760c253cSXin Li    as_abs = os.path.abspath(path)
53*760c253cSXin Li    result = os.path.relpath(as_abs, start=toolchain_utils)
54*760c253cSXin Li
55*760c253cSXin Li    if result.startswith("../"):
56*760c253cSXin Li        raise ValueError("Non toolchain-utils directory found: %s" % result)
57*760c253cSXin Li    return result
58*760c253cSXin Li
59*760c253cSXin Li
60*760c253cSXin Lidef _filter_python_tests(test_files, toolchain_utils):
61*760c253cSXin Li    """Returns all files that are real python tests."""
62*760c253cSXin Li    python_tests = []
63*760c253cSXin Li    for test_file in test_files:
64*760c253cSXin Li        rel_path = _make_relative_to_toolchain_utils(toolchain_utils, test_file)
65*760c253cSXin Li        if rel_path not in non_test_py_files:
66*760c253cSXin Li            python_tests.append(_python_test_to_spec(test_file))
67*760c253cSXin Li        else:
68*760c253cSXin Li            print("## %s ... NON_TEST_PY_FILE" % rel_path)
69*760c253cSXin Li    return python_tests
70*760c253cSXin Li
71*760c253cSXin Li
72*760c253cSXin Lidef _gather_python_tests_in(rel_subdir, toolchain_utils):
73*760c253cSXin Li    """Returns all files that appear to be Python tests in a given directory."""
74*760c253cSXin Li    subdir = os.path.join(toolchain_utils, rel_subdir)
75*760c253cSXin Li    test_files = (
76*760c253cSXin Li        os.path.join(subdir, file_name)
77*760c253cSXin Li        for file_name in os.listdir(subdir)
78*760c253cSXin Li        if file_name.endswith("_test.py") or file_name.endswith("_unittest.py")
79*760c253cSXin Li    )
80*760c253cSXin Li    return _filter_python_tests(test_files, toolchain_utils)
81*760c253cSXin Li
82*760c253cSXin Li
83*760c253cSXin Lidef _run_test(test_spec: TestSpec, timeout: int) -> Tuple[Optional[int], str]:
84*760c253cSXin Li    """Runs a test.
85*760c253cSXin Li
86*760c253cSXin Li    Returns a tuple indicating the process' exit code, and the combined
87*760c253cSXin Li    stdout+stderr of the process. If the exit code is None, the process timed
88*760c253cSXin Li    out.
89*760c253cSXin Li    """
90*760c253cSXin Li    # Each subprocess gets its own process group, since many of these tests
91*760c253cSXin Li    # spawn subprocesses for a variety of reasons. If these tests time out, we
92*760c253cSXin Li    # want to be able to clean up all of the children swiftly.
93*760c253cSXin Li    # pylint: disable=subprocess-popen-preexec-fn
94*760c253cSXin Li    with subprocess.Popen(
95*760c253cSXin Li        test_spec.command,
96*760c253cSXin Li        cwd=test_spec.directory,
97*760c253cSXin Li        stdin=subprocess.DEVNULL,
98*760c253cSXin Li        stdout=subprocess.PIPE,
99*760c253cSXin Li        stderr=subprocess.STDOUT,
100*760c253cSXin Li        encoding="utf-8",
101*760c253cSXin Li        # TODO(b/296616854): This is unsafe, and we should use
102*760c253cSXin Li        # process_group=0 when we have upgraded to Python 3.11.
103*760c253cSXin Li        preexec_fn=lambda: os.setpgid(0, 0),
104*760c253cSXin Li    ) as p:
105*760c253cSXin Li        child_pgid = p.pid
106*760c253cSXin Li        try:
107*760c253cSXin Li            out, _ = p.communicate(timeout=timeout)
108*760c253cSXin Li            return p.returncode, out
109*760c253cSXin Li        except BaseException as e:
110*760c253cSXin Li            # Try to shut the processes down gracefully.
111*760c253cSXin Li            os.killpg(child_pgid, signal.SIGINT)
112*760c253cSXin Li            try:
113*760c253cSXin Li                # 2 seconds is arbitrary, but given that these are unittests,
114*760c253cSXin Li                # should be plenty of time for them to shut down.
115*760c253cSXin Li                p.wait(timeout=2)
116*760c253cSXin Li            except subprocess.TimeoutExpired:
117*760c253cSXin Li                os.killpg(child_pgid, signal.SIGKILL)
118*760c253cSXin Li            except:
119*760c253cSXin Li                os.killpg(child_pgid, signal.SIGKILL)
120*760c253cSXin Li                raise
121*760c253cSXin Li
122*760c253cSXin Li            if isinstance(e, subprocess.TimeoutExpired):
123*760c253cSXin Li                # We just killed the entire process group. This should complete
124*760c253cSXin Li                # ~immediately. If it doesn't, something is very wrong.
125*760c253cSXin Li                out, _ = p.communicate(timeout=5)
126*760c253cSXin Li                return (None, out)
127*760c253cSXin Li            raise
128*760c253cSXin Li
129*760c253cSXin Li
130*760c253cSXin Lidef _python_test_to_spec(test_file):
131*760c253cSXin Li    """Given a .py file, convert it to a TestSpec."""
132*760c253cSXin Li    # Run tests in the directory they exist in, since some of them are sensitive
133*760c253cSXin Li    # to that.
134*760c253cSXin Li    test_directory = os.path.dirname(os.path.abspath(test_file))
135*760c253cSXin Li    file_name = os.path.basename(test_file)
136*760c253cSXin Li
137*760c253cSXin Li    if os.access(test_file, os.X_OK):
138*760c253cSXin Li        command = ["./" + file_name]
139*760c253cSXin Li    else:
140*760c253cSXin Li        # Assume the user wanted py3.
141*760c253cSXin Li        command = ["python3", file_name]
142*760c253cSXin Li
143*760c253cSXin Li    return TestSpec(directory=test_directory, command=command)
144*760c253cSXin Li
145*760c253cSXin Li
146*760c253cSXin Lidef _autodetect_python_tests_for(test_file, toolchain_utils):
147*760c253cSXin Li    """Given a test file, detect if there may be related tests."""
148*760c253cSXin Li    if not test_file.endswith(".py"):
149*760c253cSXin Li        return []
150*760c253cSXin Li
151*760c253cSXin Li    test_prefixes = ("test_", "unittest_")
152*760c253cSXin Li    test_suffixes = ("_test.py", "_unittest.py")
153*760c253cSXin Li
154*760c253cSXin Li    test_file_name = os.path.basename(test_file)
155*760c253cSXin Li    test_file_is_a_test = any(
156*760c253cSXin Li        test_file_name.startswith(x) for x in test_prefixes
157*760c253cSXin Li    ) or any(test_file_name.endswith(x) for x in test_suffixes)
158*760c253cSXin Li
159*760c253cSXin Li    if test_file_is_a_test:
160*760c253cSXin Li        test_files = [test_file]
161*760c253cSXin Li    else:
162*760c253cSXin Li        test_file_no_suffix = test_file[:-3]
163*760c253cSXin Li        candidates = [test_file_no_suffix + x for x in test_suffixes]
164*760c253cSXin Li
165*760c253cSXin Li        dir_name = os.path.dirname(test_file)
166*760c253cSXin Li        candidates += (
167*760c253cSXin Li            os.path.join(dir_name, x + test_file_name) for x in test_prefixes
168*760c253cSXin Li        )
169*760c253cSXin Li        test_files = (x for x in candidates if os.path.exists(x))
170*760c253cSXin Li    return _filter_python_tests(test_files, toolchain_utils)
171*760c253cSXin Li
172*760c253cSXin Li
173*760c253cSXin Lidef _run_test_scripts(pool, all_tests, timeout, show_successful_output=False):
174*760c253cSXin Li    """Runs a list of TestSpecs. Returns whether all of them succeeded."""
175*760c253cSXin Li    results = [
176*760c253cSXin Li        pool.apply_async(_run_test, (test, timeout)) for test in all_tests
177*760c253cSXin Li    ]
178*760c253cSXin Li
179*760c253cSXin Li    failures = []
180*760c253cSXin Li    for i, (test, future) in enumerate(zip(all_tests, results)):
181*760c253cSXin Li        # Add a bit more spacing between outputs.
182*760c253cSXin Li        if show_successful_output and i:
183*760c253cSXin Li            print("\n")
184*760c253cSXin Li
185*760c253cSXin Li        pretty_test = shlex.join(test.command)
186*760c253cSXin Li        pretty_directory = os.path.relpath(test.directory)
187*760c253cSXin Li        if pretty_directory == ".":
188*760c253cSXin Li            test_message = pretty_test
189*760c253cSXin Li        else:
190*760c253cSXin Li            test_message = "%s in %s/" % (pretty_test, pretty_directory)
191*760c253cSXin Li
192*760c253cSXin Li        print("## %s ... " % test_message, end="")
193*760c253cSXin Li        # Be sure that the users sees which test is running.
194*760c253cSXin Li        sys.stdout.flush()
195*760c253cSXin Li
196*760c253cSXin Li        exit_code, stdout = future.get()
197*760c253cSXin Li        if exit_code == 0:
198*760c253cSXin Li            print("PASS")
199*760c253cSXin Li            is_failure = False
200*760c253cSXin Li        else:
201*760c253cSXin Li            print("TIMEOUT" if exit_code is None else "FAIL")
202*760c253cSXin Li            failures.append(test_message)
203*760c253cSXin Li            is_failure = True
204*760c253cSXin Li
205*760c253cSXin Li        if show_successful_output or is_failure:
206*760c253cSXin Li            if stdout:
207*760c253cSXin Li                print("-- Stdout:\n", stdout)
208*760c253cSXin Li            else:
209*760c253cSXin Li                print("-- No stdout was produced.")
210*760c253cSXin Li
211*760c253cSXin Li    if failures:
212*760c253cSXin Li        word = "tests" if len(failures) > 1 else "test"
213*760c253cSXin Li        print(f"{len(failures)} {word} failed:")
214*760c253cSXin Li        for failure in failures:
215*760c253cSXin Li            print(f"\t{failure}")
216*760c253cSXin Li
217*760c253cSXin Li    return not failures
218*760c253cSXin Li
219*760c253cSXin Li
220*760c253cSXin Lidef _compress_list(l):
221*760c253cSXin Li    """Removes consecutive duplicate elements from |l|.
222*760c253cSXin Li
223*760c253cSXin Li    >>> _compress_list([])
224*760c253cSXin Li    []
225*760c253cSXin Li    >>> _compress_list([1, 1])
226*760c253cSXin Li    [1]
227*760c253cSXin Li    >>> _compress_list([1, 2, 1])
228*760c253cSXin Li    [1, 2, 1]
229*760c253cSXin Li    """
230*760c253cSXin Li    result = []
231*760c253cSXin Li    for e in l:
232*760c253cSXin Li        if result and result[-1] == e:
233*760c253cSXin Li            continue
234*760c253cSXin Li        result.append(e)
235*760c253cSXin Li    return result
236*760c253cSXin Li
237*760c253cSXin Li
238*760c253cSXin Lidef _fix_python_path(toolchain_utils):
239*760c253cSXin Li    pypath = os.environ.get("PYTHONPATH", "")
240*760c253cSXin Li    if pypath:
241*760c253cSXin Li        pypath = ":" + pypath
242*760c253cSXin Li    os.environ["PYTHONPATH"] = toolchain_utils + pypath
243*760c253cSXin Li
244*760c253cSXin Li
245*760c253cSXin Lidef _find_forced_subdir_python_tests(test_paths, toolchain_utils):
246*760c253cSXin Li    assert all(os.path.isabs(path) for path in test_paths)
247*760c253cSXin Li
248*760c253cSXin Li    # Directories under toolchain_utils for which any change will cause all
249*760c253cSXin Li    # tests in that directory to be rerun. Includes changes in subdirectories.
250*760c253cSXin Li    all_dirs = {
251*760c253cSXin Li        "crosperf",
252*760c253cSXin Li        "cros_utils",
253*760c253cSXin Li    }
254*760c253cSXin Li
255*760c253cSXin Li    relative_paths = [
256*760c253cSXin Li        _make_relative_to_toolchain_utils(toolchain_utils, path)
257*760c253cSXin Li        for path in test_paths
258*760c253cSXin Li    ]
259*760c253cSXin Li
260*760c253cSXin Li    gather_test_dirs = set()
261*760c253cSXin Li
262*760c253cSXin Li    for path in relative_paths:
263*760c253cSXin Li        top_level_dir = path.split("/")[0]
264*760c253cSXin Li        if top_level_dir in all_dirs:
265*760c253cSXin Li            gather_test_dirs.add(top_level_dir)
266*760c253cSXin Li
267*760c253cSXin Li    results = []
268*760c253cSXin Li    for d in sorted(gather_test_dirs):
269*760c253cSXin Li        results += _gather_python_tests_in(d, toolchain_utils)
270*760c253cSXin Li    return results
271*760c253cSXin Li
272*760c253cSXin Li
273*760c253cSXin Lidef _find_go_tests(test_paths):
274*760c253cSXin Li    """Returns TestSpecs for the go folders of the given files"""
275*760c253cSXin Li    assert all(os.path.isabs(path) for path in test_paths)
276*760c253cSXin Li
277*760c253cSXin Li    dirs_with_gofiles = set(
278*760c253cSXin Li        os.path.dirname(p) for p in test_paths if p.endswith(".go")
279*760c253cSXin Li    )
280*760c253cSXin Li    command = ["go", "test", "-vet=all"]
281*760c253cSXin Li    # Note: We sort the directories to be deterministic.
282*760c253cSXin Li    return [
283*760c253cSXin Li        TestSpec(directory=d, command=command)
284*760c253cSXin Li        for d in sorted(dirs_with_gofiles)
285*760c253cSXin Li    ]
286*760c253cSXin Li
287*760c253cSXin Li
288*760c253cSXin Lidef main(argv):
289*760c253cSXin Li    default_toolchain_utils = os.path.abspath(os.path.dirname(__file__))
290*760c253cSXin Li
291*760c253cSXin Li    parser = argparse.ArgumentParser(description=__doc__)
292*760c253cSXin Li    parser.add_argument(
293*760c253cSXin Li        "--show_all_output",
294*760c253cSXin Li        action="store_true",
295*760c253cSXin Li        help="show stdout of successful tests",
296*760c253cSXin Li    )
297*760c253cSXin Li    parser.add_argument(
298*760c253cSXin Li        "--toolchain_utils",
299*760c253cSXin Li        default=default_toolchain_utils,
300*760c253cSXin Li        help="directory of toolchain-utils. Often auto-detected",
301*760c253cSXin Li    )
302*760c253cSXin Li    parser.add_argument(
303*760c253cSXin Li        "file", nargs="*", help="a file that we should run tests for"
304*760c253cSXin Li    )
305*760c253cSXin Li    parser.add_argument(
306*760c253cSXin Li        "--timeout",
307*760c253cSXin Li        default=120,
308*760c253cSXin Li        type=int,
309*760c253cSXin Li        help="Time to allow a test to execute before timing it out, in "
310*760c253cSXin Li        "seconds.",
311*760c253cSXin Li    )
312*760c253cSXin Li    args = parser.parse_args(argv)
313*760c253cSXin Li
314*760c253cSXin Li    modified_files = [os.path.abspath(f) for f in args.file]
315*760c253cSXin Li    show_all_output = args.show_all_output
316*760c253cSXin Li    toolchain_utils = args.toolchain_utils
317*760c253cSXin Li
318*760c253cSXin Li    if not modified_files:
319*760c253cSXin Li        print("No files given. Exit.")
320*760c253cSXin Li        return 0
321*760c253cSXin Li
322*760c253cSXin Li    _fix_python_path(toolchain_utils)
323*760c253cSXin Li
324*760c253cSXin Li    tests_to_run = _find_forced_subdir_python_tests(
325*760c253cSXin Li        modified_files, toolchain_utils
326*760c253cSXin Li    )
327*760c253cSXin Li    for f in modified_files:
328*760c253cSXin Li        tests_to_run += _autodetect_python_tests_for(f, toolchain_utils)
329*760c253cSXin Li    tests_to_run += _find_go_tests(modified_files)
330*760c253cSXin Li
331*760c253cSXin Li    # TestSpecs have lists, so we can't use a set. We'd likely want to keep them
332*760c253cSXin Li    # sorted for determinism anyway.
333*760c253cSXin Li    tests_to_run.sort()
334*760c253cSXin Li    tests_to_run = _compress_list(tests_to_run)
335*760c253cSXin Li
336*760c253cSXin Li    with multiprocessing.pool.ThreadPool() as pool:
337*760c253cSXin Li        success = _run_test_scripts(
338*760c253cSXin Li            pool, tests_to_run, args.timeout, show_all_output
339*760c253cSXin Li        )
340*760c253cSXin Li    return 0 if success else 1
341*760c253cSXin Li
342*760c253cSXin Li
343*760c253cSXin Liif __name__ == "__main__":
344*760c253cSXin Li    sys.exit(main(sys.argv[1:]))
345