blob: eb24bb537518a315b8f4c58c79af5a6c12200cc7 [file] [log] [blame]
#!/usr/bin/env fuchsia-vendored-python
# Copyright 2026 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.
"""Generate a host test wrapper and associated runtime directory and runtime deps list."""
import argparse
import dataclasses
import json
import os
import shlex
import shutil
import sys
import typing as T
from pathlib import Path
sys.path.append(str(Path(__file__).parent / "../scripts"))
import build_utils
import runfiles_utils
@dataclasses.dataclass
class FuchsiaHostTestDataInfo:
"""Represents a single FuchsiaHostTestDataInfo provider."""
# LINT.IfChange(FuchsiaHostTestDataInfo)
label: str
files: dict[str, str] = dataclasses.field(default_factory=dict)
# LINT.ThenChange(//build/bazel/host_tests/host_test_data.bzl:FuchsiaHostTestDataInfo)
@staticmethod
def from_json(json_value: T.Any) -> "FuchsiaHostTestDataInfo":
assert isinstance(
json_value, dict
), f"Input must be dictionary, got {type(json_value)}"
label = json_value.get("label", "")
assert label and isinstance(
label, str
), f"Input must have a non-empty string label, got {label}"
files = json_value.get("files", {})
assert files and isinstance(
files, dict
), f"Input must have a non-empty dictionary of files, got {files}"
return FuchsiaHostTestDataInfo(
label=label,
files=files,
)
class FuchsiaHostTestDataManifest:
"""A list of FuchsiaHostTestDataInfo providers."""
def __init__(self, infos: list[FuchsiaHostTestDataInfo]):
self.infos = infos
def generate_final_map(self, bazel_execroot: Path) -> dict[str, Path]:
"""Generate final { dest_path -> source_path } map.
Returns:
A { dest_path -> source_path } map, where keys are path strings relative
to the test runtime directory, and source_path is a Path object pointing to the
actual file.
Raises:
ValueError: If there are duplicate destination paths with different source paths.
"""
# Check fo duplicates.
# Map dest_path to (label, source_path)
result: dict[str, Path] = {}
labels_map: dict[str, str] = {} # maps dest_path -> label
for info in self.infos:
for dest_path, source_path in info.files.items():
src_path = bazel_execroot / source_path
cur_source = result.setdefault(dest_path, src_path)
if cur_source != src_path:
raise ValueError(
"""
Conflict for destination path with multiple sources: {dest_path}
Labels:
{labels_map[dest_path]}
{info.label}
Sources:
{cur_source}
{source_path}
"""
)
return result
@staticmethod
def from_json(json_value: T.Any) -> "FuchsiaHostTestDataManifest":
assert isinstance(
json_value, list
), f"Input must be a list, got: {type(json_value)}"
if json_value:
assert isinstance(
json_value[0], dict
), f"Input must be a list of dicts, got: list of {type(json_value[0])}"
infos = [
FuchsiaHostTestDataInfo.from_json(info_dict)
for info_dict in json_value
]
else:
infos = []
return FuchsiaHostTestDataManifest(infos)
def find_ninja_build_dir() -> Path:
"""Find the Ninja build directory.
This only works if this script is invoked locally from the real Bazel execroot.
It works by walking up the directory tree from the current working directory
until it finds a directory containing a regenerator_outputs/ directory.
Returns:
The absolute path to the Ninja build directory.
Raises:
FileNotFoundError: If the Ninja build directory is not found.
"""
start_path = Path.cwd()
cur_path = start_path
while cur_path != cur_path.parent:
if (cur_path / "regenerator_outputs").is_dir():
return cur_path.resolve()
cur_path = cur_path.parent
raise FileNotFoundError(
f"Ninja build directory not found from: {start_path}"
)
def remove_bazel_out_prefix(bazel_path: str) -> str:
"""Remove the bazel-out/<config_dir>/bin/ prefix from a Bazel path."""
segments = bazel_path.split("/")
assert (
len(segments) > 3
and segments[0] == "bazel-out"
and segments[2] == "bin"
), f"Invalid bazel path: {bazel_path}"
return "/".join(segments[3:])
def parse_data_runfile_path(
runfile_path: str, fuchsia_dir: Path, bazel_execroot: Path
) -> tuple[str, Path]:
"""Parse a data runfile path into a canonical repo name and a short path.
Args:
runfile_path: The path to the data runfile.
fuchsia_dir: Path to the Fuchsia source directory.
bazel_execroot: Path to the Bazel execroot.
Returns:
A tuple of (rlocation, artifact_path), where rlocation is a string used both as
the key and target in output_manifest_entries, and artifact_path is a Path value
pointing to the actual file.
"""
if runfile_path.startswith("bazel-out/"):
# An artifact, the path is relative to the bazel execroot.
rlocation = "_main/" + remove_bazel_out_prefix(runfile_path)
artifact_path = bazel_execroot / runfile_path
elif runfile_path.startswith("external/"):
# An artifact path that belongs to an external repository.
rlocation = runfile_path.removeprefix("external/")
artifact_path = bazel_execroot / runfile_path
else:
# A source file, the path is relative to the workspace, which itself
# symlinks the content of the Fuchsia source directory.
rlocation = f"_main/{runfile_path}"
artifact_path = fuchsia_dir / runfile_path
return rlocation, artifact_path
def generate_test_wrapper(
entry_point: Path,
entry_runfiles_manifest: Path,
test_label: str,
output_launcher: Path,
output_runtime_dir: Path,
output_test_runtime_deps_json: Path,
host_test_data_manifest: T.Optional[Path],
host_test_wrapper_template: Path,
data_runfiles: list[str],
test_args: list[str],
bazel_execroot: Path,
) -> int:
"""Generate a Bazel host test wrapper script and related files.
This function generates three files of interest:
- A shell script used to invoke the actual test in the right work directory,
and with hard-coded test arguments.
- A directory to hold all runtime needed by the shell script. This includes the
actual test binary, and all of its runfiles. Note that the runfiles manifest has
been adjusted to only use paths relative to the runtime directory itself.
- A JSON file containing the list of all files in the runtime directory, with paths
relative to the Ninja build directory. This will be referenced by the tests.json
entries for the test.
Args:
entry_point: The path to the entry point of the actual test, relative to the
Bazel execroot. The name of that file is recorded as the test's name.
entry_runfiles_manifest: The path to the runfiles manifest of the actual test,
relative to the Bazel execroot.
test_label: The label of the wrapper test, as it will appear in tests.json.
output_launcher: The path to the output launcher script.
output_runtime_dir: The path to the output runtime directory.
output_test_runtime_deps_json: The path to the output test runtime deps JSON file.
test_args: The arguments to pass to the test.
bazel_execroot: The path to the Bazel execroot.
"""
ninja_build_dir = find_ninja_build_dir()
fuchsia_dir = build_utils.find_fuchsia_dir(from_path=ninja_build_dir)
# Read //build/bazel/BAZEL_RUNFILES.md to understand the layout of the runfiles directory.
# First, locate the input runfiles manifest, load it, and clean it up a little.
input_manifest_path = bazel_execroot / entry_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="fuchsia")
input_runfiles_dir = bazel_execroot / f"{entry_point}.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 = bazel_execroot / repo_mapping_path
assert (
repo_mapping_path.exists()
), f"Missing Bazel repository mapping file: {repo_mapping_path}"
def make_runtime_symlink(dest_path: Path, target_path: Path) -> None:
"""Create a symlink in the runtime directory.
The target path must be resolved to an absolute path before creating the symlink.
Without this, `bazel test --config=host <wrapper_test_label>` will fail because
the test is run in a sandbox, which makes relative symlinks invalid.
This does not affect `fx test`, which does not run the test in a sandbox.
For infra builds, the content of the runtime directory is uploaded as a content-addressed
directory of binary blobs after resolving the symlinks, so everything works when it
is downloaded then run separately on test runner bots.
"""
build_utils.force_raw_symlink(dest_path, target_path.resolve())
if output_runtime_dir.exists():
shutil.rmtree(output_runtime_dir)
output_runtime_dir.mkdir(parents=True, exist_ok=True)
# For every entry in the binary's runfiles manifest, create a corresponding symlink in the
# output runfiles directory, but only use paths relative to runtime_dir.
output_runfiles_dir = output_runtime_dir / f"{entry_point.name}.runfiles"
output_runfiles_dir.mkdir(parents=True, exist_ok=True)
output_manifest_entries: dict[str, str] = {}
runtime_deps_paths: list[str | Path] = []
for source_path, target_path in input_manifest.as_dict().items():
if not target_path:
# This is an empty file in the input runfiles dir, create an empty
# one in the output runfiles dir too. These are used for things like
# Python __init__.py files.
dest_path = output_runfiles_dir / source_path
manifest_path = ""
dest_path.parent.mkdir(parents=True, exist_ok=True)
dest_path.write_text("")
else:
dest_path = output_runfiles_dir / source_path
if not os.path.isabs(target_path):
target_path = f"{bazel_execroot}/{target_path}"
make_runtime_symlink(dest_path, Path(target_path))
manifest_path = source_path
output_manifest_entries[source_path] = manifest_path
runtime_deps_paths.append(dest_path)
# The data runfiles are not part of the binary's manifest and must be added to
# the runtime_dir as symlinks, and to its manifest. The paths are "short" meaning
# they are related to the bazel-bin/ directory, except those that belong in
# external repositories, which begin with ../<canonical_repo_name>/
for runfile_path in data_runfiles:
rlocation, artifact_path = parse_data_runfile_path(
runfile_path, fuchsia_dir, bazel_execroot
)
dest_path = output_runfiles_dir / rlocation
make_runtime_symlink(dest_path, artifact_path)
output_manifest_entries.setdefault(rlocation, rlocation)
runtime_deps_paths.append(dest_path)
# Create the MANIFEST file in the destination runfiles directory.
# Unlike the input manifest, it cannot contain absollute paths, and all paths are
# relative to the runtime_dir directory. This ensures that the corresponding test
# can be run in isolation on a test sharder infra bot.
exported_manifest = runfiles_utils.RunfilesManifest(
{
rlocation: f"{entry_point.name}.runfiles/{target_path}"
for rlocation, target_path in output_manifest_entries.items()
}
)
output_manifest_path = output_runfiles_dir / "MANIFEST"
output_manifest_path.write_text(exported_manifest.generate_content())
runtime_deps_paths.append(output_manifest_path)
# Create a symlink in foo.runtime_dir for the runfiles manifest.
output_manifest_symlink = (
output_runtime_dir / f"{entry_point.name}.runfiles_manifest"
)
make_runtime_symlink(output_manifest_symlink, output_manifest_path)
runtime_deps_paths.append(output_manifest_symlink)
# Create a symlink in foo.runtime_dir for the entry point.
output_entry_point = output_runtime_dir / entry_point.name
make_runtime_symlink(output_entry_point, entry_point)
runtime_deps_paths.append(output_entry_point)
# Generate the launcher script
# First separate the environment variables from the other arguments.
env_vars: list[str] = []
real_args: list[str] = []
if test_args:
for n, arg in enumerate(test_args):
varname, equal, value = arg.partition("=")
if equal != "=":
real_args = test_args[n:]
break
env_vars.append(arg)
subtitutions = {
"{{runtime_dir_location}}": os.path.relpath(
output_runtime_dir, output_launcher.parent
),
"{{test_name}}": shlex.quote(os.path.basename(entry_point)),
"{{test_args}}": " ".join([shlex.quote(arg) for arg in test_args]),
"{{env_vars}}": " ".join(shlex.quote(v) for v in env_vars),
}
launcher_text = host_test_wrapper_template.read_text()
for substitution, value in subtitutions.items():
launcher_text = launcher_text.replace(substitution, value)
output_launcher.parent.mkdir(parents=True, exist_ok=True)
output_launcher.write_text(launcher_text)
output_launcher.chmod(0o755)
# Add all host_test_data() runtime dependencies to the runtime directory
# and the runtimes_deps list, but do not add them to the generated
# Bazel runfiles manifest.
if host_test_data_manifest:
try:
with host_test_data_manifest.open() as f:
test_data_manifest = FuchsiaHostTestDataManifest.from_json(
json.load(f)
)
except Exception as e:
print(
f"ERROR: Failed to parse host_test_data_manifest: {e}",
sys.stderr,
)
return 1
host_test_data_map = test_data_manifest.generate_final_map(
bazel_execroot
)
for dest_path, source_path in host_test_data_map.items():
dest_path = output_runtime_dir / dest_path
make_runtime_symlink(dest_path, source_path)
runtime_deps_paths.append(dest_path)
# Generate the test_runtime_deps.json file.
output_test_runtime_deps_json.parent.mkdir(parents=True, exist_ok=True)
output_test_runtime_deps_json.write_text(
json.dumps(
sorted(
[
os.path.relpath(path, ninja_build_dir)
for path in runtime_deps_paths
]
)
)
)
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--entry-point",
type=Path,
required=True,
help="The entry point to wrap.",
)
parser.add_argument(
"--entry-runfiles-manifest",
type=Path,
required=True,
help="The runfiles manifest of the entry point.",
)
parser.add_argument(
"--test-label", type=str, required=True, help="The label of the test."
)
parser.add_argument(
"--output-launcher",
type=Path,
required=True,
help="The output launcher script.",
)
parser.add_argument(
"--output-runtime-dir",
type=Path,
required=True,
help="The output runtime directory.",
)
parser.add_argument(
"--output-test-runtime-deps-json",
type=Path,
required=True,
help="The output runtime_deps.json file.",
)
parser.add_argument(
"--data-runfile",
action="append",
default=[],
type=str,
help="Data runfiles to include in the test's runfiles.",
)
parser.add_argument(
"--host-test-data-manifest",
type=Path,
help="An input manifest describing host_test_data() runtime dependencies.",
)
parser.add_argument(
"--host-test-wrapper-template",
type=Path,
required=True,
help="Input path to test wrapper script template.",
)
parser.add_argument(
"--test-arg",
action="append",
type=str,
default=[],
help="Extra arguments passed to the test entry point.",
)
parser.add_argument(
"--bazel-execroot",
type=Path,
default=Path.cwd(),
help="The Bazel execroot (default to current directory).",
)
args = parser.parse_args()
return generate_test_wrapper(
args.entry_point,
args.entry_runfiles_manifest,
args.test_label,
args.output_launcher,
args.output_runtime_dir,
args.output_test_runtime_deps_json,
args.host_test_data_manifest,
args.host_test_wrapper_template,
args.data_runfile,
args.test_arg,
args.bazel_execroot,
)
if __name__ == "__main__":
sys.exit(main())