| # Copyright 2020 The Fuchsia Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| import argparse |
| import collections |
| import json |
| import os |
| import subprocess |
| import sys |
| |
| NO_WORK_TEMPLATE = "ninja: Entering directory `%s'\nninja: no work to do." |
| |
| |
| def main(): |
| parser = argparse.ArgumentParser( |
| description="Finds and runs tests affected by current change" |
| ) |
| parser.add_argument( |
| "--tests-json", |
| required=True, |
| type=argparse.FileType("rb"), |
| help="Path to tests.json", |
| ) |
| parser.add_argument( |
| "--changed-srcs", required=True, nargs="+", help="Changed source paths" |
| ) |
| parser.add_argument("--ninja", required=True, help="Path to the Ninja executable") |
| parser.add_argument( |
| "--fuchsia_targets", required=False, nargs="*", help="Ninja targets for Fuchsia" |
| ) |
| parser.add_argument( |
| "--fuchsia-out-dir", required=True, help="Path to the Fuchsia out directory" |
| ) |
| parser.add_argument( |
| "--ninja-out", required=False, help="Path to write Ninja output to" |
| ) |
| parser.add_argument( |
| "--no-work-status", |
| required=False, |
| help=( |
| "Path to write a JSON boolean indicating whether we concluded there " |
| "was no work to do" |
| ), |
| ) |
| args = parser.parse_args() |
| |
| # Index tests.json |
| tests = json.load(args.tests_json) |
| stamp_to_test_names = collections.defaultdict(set) |
| path_to_test_name = {} |
| for entry in tests: |
| # For host tests, we search for the executable path. |
| path = entry["test"].get("path") |
| if path: |
| path_to_test_name[path] = entry["test"]["name"] |
| continue |
| # For Fuchsia tests we derive the stamp path from the GN label. |
| label = entry["test"]["label"] |
| # Remove leading "//" and toolchain part |
| # "//my/gn:path(//build/toolchain:host_x64)" -> "my/gn:path" |
| no_toolchain = label[2:].partition("(")[0] |
| # Convert label to stamp path |
| # "my/gn:path" -> "obj/my/gn/path.stamp" |
| # "foo/bar:bar" -> "obj/foo/bar.stamp" |
| directory, _, name = no_toolchain.partition(":") |
| if not name: |
| # "foo/bar" -> "foo/bar" |
| stamp = directory |
| elif directory.rpartition("/")[2] == name: |
| # "foo/bar:bar" -> "foo/bar" |
| stamp = directory |
| else: |
| # "foo/bar:qux" -> "foo/bar/qux" |
| stamp = directory + "/" + name |
| stamp = os.path.join("obj", stamp + ".stamp") |
| stamp_to_test_names[stamp].add(entry["test"]["name"]) |
| |
| def touch_and_dry_run(srcs, ignore_extensions): |
| ignored = [] |
| # Touch all changed files so they're newer than any stamps |
| for src in srcs: |
| if os.path.exists(src): |
| if os.path.splitext(src)[1] in ignore_extensions: |
| ignored.append(src) |
| else: |
| os.utime(src, None) |
| |
| # Build Fuchsia |
| ninja_output = subprocess.check_output( |
| [args.ninja, "-C", args.fuchsia_out_dir, "-d", "explain", "-n", "-v"] |
| + (args.fuchsia_targets or []), |
| stderr=subprocess.STDOUT, |
| ).decode("UTF-8") |
| |
| return ninja_output, ignored |
| |
| # Our Ninja graph is set up in such a way that touching any GN files |
| # triggers an action to regenerate the entire graph. So if GN files |
| # were modified and we touched them then the following dry run results |
| # are not useful for determining affected tests. |
| ignore_extensions = (".gn", ".gni") |
| ninja_output, ignored = touch_and_dry_run(args.changed_srcs, ignore_extensions) |
| |
| if args.ninja_out: |
| with open(args.ninja_out, "wt") as outfile: |
| outfile.write(ninja_output) |
| |
| # Example line: |
| # [4/5] some_executable arg1 arg2 |
| # becomes action: |
| # some_executable arg1 arg2 |
| actions = [line.partition("] ")[2] for line in ninja_output.splitlines()] |
| |
| # Check stale actions against tests. |
| affected_tests = set() |
| for action in actions: |
| # looking for actions like "touch baz/obj/foo/bar.stamp" |
| if action.startswith("touch ") and "obj/" in action: |
| test_names = stamp_to_test_names[action[action.index("obj/") :]] |
| affected_tests |= test_names |
| # Looking for any action that includes the test path. |
| # Different types of host tests have different actions, but they |
| # all mention the final executable path. |
| else: |
| action_parts = [part.strip('"') for part in action.split()] |
| for action_part in action_parts: |
| test_name = path_to_test_name.get(action_part) |
| if test_name: |
| affected_tests.add(test_name) |
| |
| # The following tests should never be considered affected |
| never_affected = ( |
| # These tests use a system image as data, so they appear affected by a |
| # broad range of changes, but they're almost never actually sensitive |
| # to said changes. fxbug.dev/67305 tracks generating this list automatically. |
| "overnet_serial_tests", |
| "recovery_simulator_boot_test", |
| "recovery_simulator_serial_test", |
| ) |
| for name in never_affected: |
| affected_tests.discard(name) |
| |
| for test in sorted(affected_tests): |
| print(test) |
| |
| if args.no_work_status: |
| # For determination of "no work to do", we want to consider all files. |
| if ignored: |
| ninja_output, _ = touch_and_dry_run(ignored, ()) |
| |
| no_work = (NO_WORK_TEMPLATE % args.fuchsia_out_dir) in ninja_output |
| with open(args.no_work_status, "w") as f: |
| json.dump(no_work, f) |
| |
| |
| if __name__ == "__main__": |
| main() |