| #!/usr/bin/env fuchsia-vendored-python |
| # Copyright 2025 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. |
| |
| """Describe or export Bazel host test artifacts to the Ninja build directory.""" |
| |
| import argparse |
| import dataclasses |
| import json |
| import os |
| import shutil |
| import sys |
| import typing as T |
| from pathlib import Path |
| |
| _SCRIPT_DIR = os.path.dirname(__file__) |
| _SCRIPT_DIR_PATH = Path(_SCRIPT_DIR) |
| sys.path.insert(0, _SCRIPT_DIR) |
| import build_utils |
| import runfiles_utils |
| |
| # The main workspace name as it appears in the runfiles directory. |
| # For the Fuchsia build, which is still using Bazel 7.x and WORKSPACE |
| # files, this is "main", but will become `_main` when enabling BzlMod |
| _MAIN_WORKSPACE_NAME = "main" |
| |
| _LIST_TESTS_COMMAND_HELP = r""" |
| Generate a Fuchsia-compatible `tests.json` file listing host test targets |
| reachable from one ore more Bazel target pattern(s). This can be called |
| directly after `fx set` or `fx gen`, as it does not require building anything. |
| |
| The only difference from the GN-generated `tests.json` file is that each |
| entry's target label begins with an `@`, and does not include any toolchain |
| suffix. For example, the following command: |
| |
| ```bash |
| python3 build/bazel/scripts/export_host_test.py \ |
| list_tests \ |
| //build/bazel/host_tests/static_cc_test/... |
| ``` |
| |
| Produces the following output: |
| |
| ```json |
| [ |
| { |
| "environments": [ |
| { |
| "dimensions": { |
| "cpu": "x64", |
| "os": "Linux" |
| } |
| } |
| ], |
| "expects_ssh": false, |
| "label": "@//build/bazel/host_tests/static_cc_test:test_program", |
| "name": "bazel_host_tests/build/bazel/host_tests/static_cc_test/test_program", |
| "path": "bazel_host_tests/build/bazel/host_tests/static_cc_test/test_program", |
| "runtime_deps": "bazel_host_tests/build/bazel/host_tests/static_cc_test/test_program.runtime_deps.json", |
| "os": "linux", |
| "cpu": "x64" |
| } |
| ] |
| ``` |
| |
| All exported test artifacts are located under $NINJA_BUILD_DIR/bazel_host_tests/ by default. This |
| location can be changed with the --export-subdir=SUBDIR argument. The rest of the path must follow |
| the Bazel-specific execroot-relative file layout. |
| |
| Use the `export` command to create the test artifacts and the `.runtime_deps.json` file. |
| """ |
| |
| _EXPORT_COMMAND_HELP = r"""Export Bazel test artifacts to the Ninja build directory. Making them |
| usable outside of the Bazel output base. |
| |
| For example, the following command: |
| |
| ```bash |
| python3 build/bazel/scripts/export_host_test.py \ |
| export \ |
| //build/bazel/host_tests/static_cc_test/... |
| ``` |
| |
| Will populate the build directory with files that look like: |
| |
| ``` |
| bazel_host_tests/build/bazel/host_tests/ |
| static_cc_test/ |
| test_program |
| test_program.runtime_deps.json |
| test_program.repo_mapping |
| test_program.runfiles_manifest |
| test_program.runfiles/ |
| ... |
| ``` |
| |
| Where `test_program` is the test entry point that is listed in the |
| corresponding entry generated by the `list` command. |
| |
| NOTE: In many cases, the test executable (e.g. `test_program` here) *MUST* be invoked from |
| the Ninja build directory to work correctly. |
| """ |
| |
| # Location of the file containing the Starlark cquery code to run in this script |
| # to locate all test targets. |
| _CQUERY_STARLARK_FILE_PATH = ( |
| _SCRIPT_DIR_PATH.parent / "starlark" / "host_test_info.cquery" |
| ) |
| |
| # The sub-directory, relative to the Ninja build directory, where all Bazel host test |
| # artifacts will be exported by this script. The content will be mostly symlinks into |
| # the Bazel output base, unless the --no-symlinks option is used. |
| _DEFAULT_EXPORT_SUBDIR = "bazel_host_tests" |
| |
| |
| class HostInfo(object): |
| """Convenience class to expose host-specific information.""" |
| |
| def __init__(self) -> None: |
| self._os = build_utils.get_host_platform() |
| self._cpu = build_utils.get_host_arch() |
| |
| @property |
| def os(self) -> str: |
| """The host operating system name, following Fuchsia conventions.""" |
| return self._os |
| |
| @property |
| def cpu(self) -> str: |
| """The host cpu architecture name, following Fuchsia conventions.""" |
| return self._cpu |
| |
| @property |
| def test_json_environments(self) -> list[dict[str, T.Any]]: |
| """The value of a tests.json "environments" key for a given host test.""" |
| # See https://fuchsia.dev/fuchsia-src/reference/testing/tests-json-format?hl=en#environments_field |
| # and //build/testing/environments.gni. |
| # There are only two host environment supported, Linux and MacOS |
| # using environments[].dimensions.os values "Linux" and "Mac" |
| # respectively. |
| host_os = {"linux": "Linux", "mac": "Mac"}.get(self._os) |
| assert host_os, f"Unsupported host operating system: {self._os}" |
| |
| return [ |
| { |
| "dimensions": { |
| "cpu": self._cpu, |
| "os": host_os, |
| } |
| } |
| ] |
| |
| |
| def hardlink_or_copy_file(src_path: Path, dst_path: Path) -> None: |
| """Hardlink or copy a source file to a destination path. |
| |
| If the source path is a directory, ValueError is raised. |
| |
| If the source path is a symlink, a symlink is created at the destination |
| that points to the same location, but using a destination-relative target path. |
| |
| If the hardlink fails, a copy operation will be performed. |
| |
| Args: |
| src_path: Source file path. |
| dst_path: Destination path. |
| Raise: |
| ValueError if src_path is a directory path. |
| """ |
| # Resolve symlink source paths. |
| if src_path.is_dir(): |
| raise ValueError(f"Cannot hard-link or copy directory: {src_path}") |
| |
| dst_path.parent.mkdir(parents=True, exist_ok=True) |
| if dst_path.is_symlink(): |
| dst_path.unlink() |
| |
| if src_path.is_symlink(): |
| dst_path.symlink_to( |
| os.path.relpath(src_path.resolve(), dst_path.parent) |
| ) |
| else: |
| try: |
| dst_path.hardlink_to(src_path) |
| except Exception: |
| shutil.copyfile(src_path, dst_path) |
| |
| |
| def filter_bazel_bin_path(bazel_path: str) -> str: |
| """Filter the bazel-out/<config-dir>/bin/ prefix of an execroot-relative path. |
| |
| Args: |
| bazel_path: A path relative to the Bazel execroot, which for artifacts |
| must begin with `bazel-out/<config_dir>/bin/`. |
| Returns: |
| The same path without the `bazel-out/<config_dir>/bin/` prefix. |
| Raises: |
| ValueError if the path is not formed properly. |
| """ |
| segments = bazel_path.split("/") |
| if len(segments) <= 3 or segments[0] != "bazel-out" or segments[2] != "bin": |
| raise ValueError( |
| f"Expected execroot-relative artifact path, got [{bazel_path}]" |
| ) |
| return "/".join(segments[3:]) |
| |
| |
| class HostTestInfo(object): |
| """Record information about a given host test, and produce outputs accordingly.""" |
| |
| def __init__( |
| self, |
| test_input: dict[str, T.Any], |
| host_info: HostInfo, |
| export_subdir: str = "", |
| ) -> None: |
| """Create new instance. |
| |
| Args: |
| test_input: A dictionary modeling the host_test_info.cquery output |
| for the Bazel test target. |
| |
| host_info: A HostInfo instance describing the current host system. |
| |
| export_subdir: An optional sub-directory path, relative to the export directory. |
| The default is an empty string, meaning no sub-directory will be used. |
| Raises: |
| AssertionError is test_input is malformed. |
| """ |
| self._host_info = host_info |
| self._export_prefix = f"{export_subdir}/" if export_subdir else "" |
| # LINT.IfChange |
| self._input = test_input |
| self._label = self._input["label"] |
| assert self._label.startswith( |
| "@" |
| ), f"Invalid Bazel target label does not begin with @: {self._label}" |
| |
| # The path to the tests's entry point, relative to the Ninja build directory |
| # (or the top of the test archive). Because the test archive does not support |
| # symlinks, this will be a shell script that invokes the real test binary |
| # with an "exec" call. |
| self._entry_path = self._export_prefix + filter_bazel_bin_path( |
| self._input["executable"] |
| ) |
| |
| # The path to the file describing the runtime dependencies for the host test. |
| # Its content can only be generated after building the test, as its depends on |
| # the runfiles manifest's content, which is not available through Bazel cqueries. |
| self._runtime_deps_path = f"{self._entry_path}.runtime_deps.json" |
| # LINT.ThenChange(../starlark/host_test_info.cquery) |
| |
| def generate_tests_json_entry(self) -> dict[str, T.Any]: |
| """Generate a single tests.json entry describing the current host test. |
| |
| Returns: |
| A new JSON dictionary describing the host test, following the schema at |
| https://fuchsia.dev/fuchsia-src/reference/testing/tests-json-format |
| """ |
| # For now, use the test path as the name. `fx test` will use the basename |
| # to find it. |
| name = self._entry_path |
| |
| return { |
| "environments": self._host_info.test_json_environments, |
| "expects_ssh": False, |
| "label": self._label, |
| "name": name, |
| "path": self._entry_path, |
| "runtime_deps": self._runtime_deps_path, |
| "os": self._host_info.os, |
| "cpu": self._host_info.cpu, |
| } |
| |
| def export_to(self, build_dir: Path, execroot: Path) -> None: |
| """Export this host test to a Ninja {build_dir}. |
| |
| Mirror the executable and its runfiles support files into the |
| Ninja build directory into {build_dir}/{export_subdir}/ |
| |
| For example, given the following Bazel test files: |
| |
| ``` |
| {EXECROOT}/bazel-out/k8-fastbuild/bin/ |
| src/ |
| foo |
| foo.repo_manifest |
| foo.runfiles_manifest |
| foo.runfiles/ |
| _main/ |
| src/ |
| foo ---> {EXECROOT}/bazel-out/k8-fastbuild/bin/src/foo |
| data/ |
| file ---> {WORKSPACE}/data/file |
| _repo_mapping ---> {EXECROOT}/bazel-out/k8-fastbuild/bin/src/foo.repo_mapping |
| MANIFEST ---> {EXECROOT}/bazel-out/k8-fastbuild/bin/src/foo.runfiles_manifest |
| ``` |
| |
| This will create the following in the Ninja {build_dir}: |
| |
| ``` |
| {export_subdir}/ |
| src/ |
| foo --> {EXECROOT}/bazel-out/k8-fastbuild/bin/src/foo |
| foo.repo_manifest --> {EXECROOT}/bazel-out/k8-fastbuild/ |
| foo.runfiles_manifest # ADJUSTED MANIFEST (see below) |
| foo.runtime_deps.json |
| foo.runfiles/ |
| _main/ |
| src/ |
| foo ---> {EXECROOT}/bazel-out/k8-fastbuild/bin/src/foo |
| data/ |
| file ---> {WORKSPACE}/data/file |
| _repo_mapping ---> {EXECROOT}/bazel-out/k8-fastbuild/bin/src/foo.repo_mapping |
| MANIFEST ---> {build_dir}/{export_subdir}/src/foo.runfiles_manifest |
| ``` |
| |
| The major differences are: |
| |
| - The `{export_subdir}/src/foo.runtime_deps.json` file, which will be referenced from |
| `tests.json`, which contains a JSON list of paths to all the runtime-required files, |
| relative to the Ninja {build_dir}. In this case, this will look like: |
| |
| ``` |
| {export_subdir}/src/foo |
| {export_subdir}/src/foo.repo_mapping |
| {export_subdir}/src/foo.runfiles_manifest |
| {export_subdir}/src/foo.runfiles/_main/src/foo |
| {export_subdir}/src/foo.runfiles/_main/data/file |
| {export_subdir}/src/foo.runfiles/MANIFEST |
| ``` |
| |
| - The `{export_subdir}/src/foo.runtime_manifest` is adjusted to list target paths that |
| are also relative to the Ninja {build_dir}, so will contain something like: |
| |
| ``` |
| _main/src/foo {export_subdir}/src/foo.runfiles/_main/src/foo |
| _main/data/file {export_subdir}/src/foo.runfiles/_main/data/file |
| _repo_mapping {export_subdir}/src/foo.runfiles/_repo_mapping |
| ``` |
| |
| IMPORTANT: Running the test requires launching it from the Ninja {build_dir}, though |
| there are exceptions for py_test() and sh_test() targets, because they use |
| a middle-man script that can probe the environment and file-system to adjust |
| paths accordingly. |
| |
| Args: |
| build_dir: Root export directory, a.k.a. Ninja build directory. |
| execroot: Bazel execroot absolute path. Used to resolve relative paths |
| in runfiles manifest. |
| """ |
| # First, locate the input runfiles manifest, load it, and clean it up a little. |
| input_manifest_path = execroot / self._input["runfiles_manifest"] |
| assert ( |
| input_manifest_path.exists() |
| ), f"Missing Bazel runfiles manifest: {input_manifest_path}" |
| input_manifest = runfiles_utils.RunfilesManifest.CreateFrom( |
| input_manifest_path.read_text() |
| ) |
| input_manifest.remove_legacy_external_runfiles( |
| workspace_name=_MAIN_WORKSPACE_NAME |
| ) |
| |
| input_runfiles_dir = execroot / f"{self._input['executable']}.runfiles" |
| assert ( |
| input_runfiles_dir.exists() |
| ), f"Missing Bazel runfiles directory: {input_runfiles_dir}" |
| |
| # Second, locate the _repo_mapping file from it. This should be an absolute path or |
| # an execroot-relative one. |
| repo_mapping_path = Path(input_manifest.lookup("_repo_mapping")) |
| assert ( |
| repo_mapping_path |
| ), f"Missing _repo_mapping entry from runfiles manifest at: {input_manifest_path}" |
| if not repo_mapping_path.is_absolute(): |
| repo_mapping_path = execroot / repo_mapping_path |
| assert ( |
| repo_mapping_path.exists() |
| ), f"Missing Bazel repository mapping file: {repo_mapping_path}" |
| |
| # For every entry in the manifest, create a corresponding symlink in the output runfiles directory. |
| output_runfiles_dir = build_dir / f"{self._entry_path}.runfiles" |
| if output_runfiles_dir.exists(): |
| shutil.rmtree(output_runfiles_dir) |
| output_runfiles_dir.mkdir(parents=True) |
| |
| runtime_deps = [] |
| output_manifest_entries: dict[str, str] = {} |
| for source_path, target_path in input_manifest.as_dict().items(): |
| dest_path = output_runfiles_dir / source_path |
| runtime_path = os.path.relpath(dest_path, build_dir) |
| |
| if not target_path: |
| # This is an empty file in the input runfiles dir, create an empty |
| # one in the output runfiles dir too. |
| dest_path.parent.mkdir(parents=True, exist_ok=True) |
| dest_path.write_text("") |
| else: |
| if not os.path.isabs(target_path): |
| target_path = f"{execroot}/{target_path}" |
| build_utils.force_symlink(dest_path, target_path) |
| |
| runtime_deps.append(runtime_path) |
| output_manifest_entries[source_path] = runtime_path |
| |
| # Create the MANIFEST file in the destination runfiles directory. |
| exported_manifest = runfiles_utils.RunfilesManifest( |
| output_manifest_entries |
| ) |
| output_manifest_path = output_runfiles_dir / "MANIFEST" |
| output_manifest_path.write_text(exported_manifest.generate_content()) |
| runtime_deps.append(os.path.relpath(output_manifest_path, build_dir)) |
| |
| # Add foo.runfiles_manifest and foo.repo_mapping symlinks as well. |
| output_manifest_link_path = ( |
| build_dir / f"{self._entry_path}.runfiles_manifest" |
| ) |
| build_utils.force_symlink( |
| output_manifest_link_path, output_manifest_path |
| ) |
| runtime_deps.append( |
| os.path.relpath(output_manifest_link_path, build_dir) |
| ) |
| |
| output_repo_mapping_link_path = ( |
| build_dir / f"{self._entry_path}.repo_mapping" |
| ) |
| build_utils.force_symlink( |
| output_repo_mapping_link_path, output_runfiles_dir / "_repo_mapping" |
| ) |
| runtime_deps.append( |
| os.path.relpath(output_repo_mapping_link_path, build_dir) |
| ) |
| |
| # Write the foo.runtime_deps.json file. |
| output_runtime_deps_path = build_dir / self._runtime_deps_path |
| output_runtime_deps_path.write_text(json.dumps(runtime_deps, indent=2)) |
| |
| # Finally, hardlink or copy the executable itself. A symlink will not work for |
| # Python and shell tests because the executable is a middleman script that may |
| # use expressions like |`readlink $0`.runfiles| to find the runfiles directory |
| # and this will not work. |
| hardlink_or_copy_file( |
| execroot / self._input["executable"], |
| build_dir / self._entry_path, |
| ) |
| |
| |
| @dataclasses.dataclass |
| class CommandContext(object): |
| """Common context for all script commands.""" |
| |
| args: argparse.Namespace |
| build_dir: Path |
| tests_map: dict[str, HostTestInfo] |
| bazel_cmd: build_utils.BazelCommand |
| |
| |
| def list_command(args: argparse.Namespace, context: CommandContext) -> int: |
| """Implement the `list` script command.""" |
| # Create a tests.json file corresponding to the test_info entries. |
| tests_json = [] |
| for label, test_info in sorted(context.tests_map.items()): |
| tests_json.append(test_info.generate_tests_json_entry()) |
| |
| content = json.dumps(tests_json, indent=2) |
| if args.output: |
| args.output.parent.mkdir(parents=True, exist_ok=True) |
| args.output.write_text(content) |
| else: |
| print(content) |
| |
| return 0 |
| |
| |
| def export_command(args: argparse.Namespace, context: CommandContext) -> int: |
| """Implement the `export` command.""" |
| execroot = context.bazel_cmd.get_execroot() |
| |
| # Build them, as we require the runfiles manifest file to parse it. |
| context.bazel_cmd.run("build", sorted(args.target_pattern)) |
| |
| build_dir = context.build_dir |
| if args.output_dir: |
| build_dir = args.output_dir |
| |
| for label, test_info in context.tests_map.items(): |
| test_info.export_to(build_dir, execroot) |
| |
| return 0 |
| |
| |
| def main() -> int: |
| parser = argparse.ArgumentParser(description=__doc__) |
| parser.add_argument( |
| "--fuchsia-dir", |
| type=Path, |
| help="Path to Fuchsia checkout (auto-detected).", |
| ) |
| parser.add_argument( |
| "--build-dir", |
| type=Path, |
| help="Path to Fuchsia build directory (auto-detected).", |
| ) |
| parser.add_argument( |
| "--export-subdir", |
| type=Path, |
| default=_DEFAULT_EXPORT_SUBDIR, |
| help=f"Set build_dir sub-directory for export (default {_DEFAULT_EXPORT_SUBDIR}).", |
| ) |
| subparsers = parser.add_subparsers(required=True) |
| |
| parser_list = subparsers.add_parser( |
| "list", |
| help="Generate a tests.json file from one or more Bazel target patterns.", |
| formatter_class=argparse.RawTextHelpFormatter, |
| description=_LIST_TESTS_COMMAND_HELP, |
| ) |
| parser_list.add_argument( |
| "--output", |
| type=Path, |
| help="Write result to file, default goes to stdout.", |
| ) |
| parser_list.add_argument( |
| "target_pattern", nargs="+", help="Bazel host test label patterns" |
| ) |
| parser_list.set_defaults(func=list_command) |
| |
| parser_export = subparsers.add_parser( |
| "export", |
| help="Export Bazel test artifacts to the Ninja build directory.", |
| description=_EXPORT_COMMAND_HELP, |
| formatter_class=argparse.RawTextHelpFormatter, |
| ) |
| parser_export.add_argument( |
| "--output-dir", |
| type=Path, |
| help="Specify alternative output directory, default is Ninja build directory.", |
| ) |
| parser_export.add_argument( |
| "target_pattern", nargs="+", help="Bazel host test label patterns" |
| ) |
| parser_export.set_defaults(func=export_command) |
| |
| args = parser.parse_args() |
| |
| bazel_paths = build_utils.BazelPaths.new(args.fuchsia_dir, args.build_dir) |
| |
| bazel_launcher = bazel_paths.launcher |
| if not bazel_launcher.exists(): |
| parser.error( |
| f"Could not find Bazel launcher script! fuchsia_dir={fuchsia_dir} build_dir={build_dir}" |
| ) |
| |
| bazel_cmd = build_utils.BazelCommand(bazel_launcher) |
| |
| # Now query each target to get its executable and runfiles_manifest path |
| # in the output base, then load the runfiles manifest. |
| patterns = " ".join(args.target_pattern) |
| output = bazel_cmd.run( |
| "cquery", |
| [ |
| "--output=starlark", |
| f"--starlark:file={_CQUERY_STARLARK_FILE_PATH.resolve()}", |
| f"tests(set({patterns}))", |
| ], |
| ) |
| |
| host_info = HostInfo() |
| tests_map = {} |
| for line in output.splitlines(): |
| test_entry = json.loads(line) |
| test_info = HostTestInfo(test_entry, host_info, args.export_subdir) |
| tests_map[test_entry["label"]] = test_info |
| |
| context = CommandContext( |
| args, bazel_paths.ninja_build_dir, tests_map, bazel_cmd |
| ) |
| return args.func(args, context) |
| |
| |
| if __name__ == "__main__": |
| sys.exit(main()) |