xref: /aosp_15_r20/external/bazelbuild-rules_testing/lib/util.bzl (revision d605057434dcabba796c020773aab68d9790ff9f)
1# Copyright 2023 The Bazel Authors. All rights reserved.
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
15"""# Util
16
17Various utilities to aid with testing.
18"""
19
20load("@bazel_skylib//lib:paths.bzl", "paths")
21load("@bazel_skylib//lib:types.bzl", "types")
22
23# TODO(ilist): remove references to skylib analysistest
24load("@bazel_skylib//lib:unittest.bzl", "analysistest")
25load("@bazel_skylib//rules:write_file.bzl", "write_file")
26
27_SKIP_CI_TAGS = [
28    # copybara-marker: skip-ci-tag
29]
30
31# We add the manual tag to prevent implicitly building and running the subject
32# targets. When the rule-under-test is a test rule, it prevents trying to run
33# it. For binary rules, it prevents implicitly building it (and thus activating
34# more validation logic) when --build_tests_only is enabled.
35PREVENT_IMPLICIT_BUILDING_TAGS = [
36    "manual",  # Prevent `bazel ...` from directly building them
37    # copybara-marker: skip-coverage-tag
38] + _SKIP_CI_TAGS
39PREVENT_IMPLICIT_BUILDING = {"tags": PREVENT_IMPLICIT_BUILDING_TAGS}
40
41def merge_kwargs(*kwargs):
42    """Merges multiple dicts of kwargs.
43
44    This is similar to dict.update except:
45        * If a key's value is a list, it'll be concatenated to any existing value.
46        * An error is raised when the same non-list key occurs more than once.
47
48    Args:
49        *kwargs: kwarg arg dicts to merge
50
51    Returns:
52        dict of the merged kwarg dics.
53    """
54    final = {}
55    for kwarg in kwargs:
56        for key, value in kwarg.items():
57            if types.is_list(value):
58                final[key] = final.get(key, []) + value
59            elif key in final:
60                fail("Key already exists: {}: {}".format(key, final[key]))
61            else:
62                final[key] = value
63    return final
64
65def empty_file(name):
66    """Generates an empty file and returns the target name for it.
67
68    Args:
69        name: str, name of the generated output file.
70
71    Returns:
72        str, the name of the generated output.
73    """
74    write_file(
75        name = "write_" + name,
76        content = [],
77        out = name,
78    )
79    return name
80
81def helper_target(rule, **kwargs):
82    """Define a target only used as a Starlark test input.
83
84    This is useful for e.g. analysis tests, which have to setup a small
85    graph of targets that should only be built via the test (e.g. they
86    may require config settings the test sets). Tags are added to
87    hide the target from `:all`, `/...`, TAP, etc.
88
89    Args:
90        rule: rule-like function.
91        **kwargs: Any kwargs to pass to `rule`. Additional tags will
92            be added to hide the target.
93    """
94    kwargs = merge_kwargs(kwargs, PREVENT_IMPLICIT_BUILDING)
95    rule(**kwargs)
96
97def short_paths(files_depset):
98    """Returns the `short_path` paths for a depset of files."""
99    return [f.short_path for f in files_depset.to_list()]
100
101def runfiles_paths(workspace_name, runfiles):
102    """Returns the root-relative short paths for the files in runfiles.
103
104    Args:
105        workspace_name: str, the workspace name (`ctx.workspace_name`).
106        runfiles: runfiles, the runfiles to convert to short paths.
107
108    Returns:
109        list of short paths but runfiles root-relative. e.g.
110        'myworkspace/foo/bar.py'.
111    """
112    paths = []
113    paths.extend(short_paths(runfiles.files))
114    paths.extend(runfiles.empty_filenames.to_list())
115    paths.extend(_runfiles_symlink_paths(runfiles.symlinks))
116    paths = _prepend_path(workspace_name, paths)
117
118    paths.extend(_runfiles_symlink_paths(runfiles.root_symlinks))
119    return paths
120
121def runfiles_map(workspace_name, runfiles):
122    """Convert runfiles to a path->file mapping.
123
124    This approximates how Bazel materializes the runfiles on the file
125    system.
126
127    Args:
128        workspace_name: str; the workspace the runfiles belong to.
129        runfiles: runfiles; the runfiles to convert to a map.
130
131    Returns:
132        `dict[str, optional File]` that maps the path under the runfiles root
133        to it's backing file. The file may be None if the path came
134        from `runfiles.empty_filenames`.
135    """
136    path_map = {}
137    workspace_prefix = workspace_name + "/"
138    for file in runfiles.files.to_list():
139        path_map[workspace_prefix + file.short_path] = file
140    for path in runfiles.empty_filenames.to_list():
141        path_map[workspace_prefix + path] = None
142
143    # NOTE: What happens when different files have the same symlink isn't
144    # exactly clear. For lack of a better option, we'll just take the last seen
145    # value.
146    for entry in runfiles.symlinks.to_list():
147        path_map[workspace_prefix + entry.path] = entry.target_file
148    for entry in runfiles.root_symlinks.to_list():
149        path_map[entry.path] = entry.target_file
150    return path_map
151
152def _prepend_path(prefix, path_strs):
153    return [paths.join(prefix, p) for p in path_strs]
154
155def _runfiles_symlink_paths(symlinks_depset):
156    return [entry.path for entry in symlinks_depset.to_list()]
157
158TestingAspectInfo = provider(
159    "Details about a target-under-test useful for testing.",
160    fields = {
161        "attrs": "The raw attributes of the target under test.",
162        "actions": "The actions registered for the target under test.",
163        "vars": "The var dict (ctx.var) for the target under text.",
164        "bin_path": "str; the ctx.bin_dir.path value (aka execroot).",
165    },
166)
167
168def _testing_aspect_impl(target, ctx):
169    return [TestingAspectInfo(
170        attrs = ctx.rule.attr,
171        actions = target.actions,
172        vars = ctx.var,
173        bin_path = ctx.bin_dir.path,
174    )]
175
176# TODO(ilist): make private, after switching python tests to new testing framework
177testing_aspect = aspect(
178    implementation = _testing_aspect_impl,
179)
180
181# The same as `testing_aspect`, but recurses through all attributes in the
182# whole graph. This is useful if you need to extract information about
183# targets that aren't direct dependencies of the target under test, or to
184# reconstruct a more complete graph of inputs/outputs/generating-target.
185# TODO(ilist): make private, after switching python tests to new testing framework
186recursive_testing_aspect = aspect(
187    implementation = _testing_aspect_impl,
188    attr_aspects = ["*"],
189)
190
191def get_target_attrs(env):
192    return analysistest.target_under_test(env)[TestingAspectInfo].attrs
193
194# TODO(b/203567235): Remove this after cl/382467002 lands and the regular
195# `analysistest.target_actions()` can be used.
196def get_target_actions(env):
197    return analysistest.target_under_test(env)[TestingAspectInfo].actions
198
199def is_runfiles(obj):
200    """Tells if an object is a runfiles object."""
201    return type(obj) == "runfiles"
202
203def is_file(obj):
204    """Tells if an object is a File object."""
205    return type(obj) == "File"
206
207def skip_test(name):
208    """Defines a test target that is always skipped.
209
210    This is useful for tests that should be skipped if some condition,
211    determinable during the loading phase, isn't met. The resulting target will
212    show up as "SKIPPED" in the output.
213
214    If possible, prefer to use `target_compatible_with` to mark tests as
215    incompatible. This avoids confusing behavior where the type of a target
216    varies depending on loading-phase behavior.
217
218    Args:
219      name: The name of the target.
220    """
221    _skip_test(
222        name = name,
223        target_compatible_with = ["@platforms//:incompatible"],
224        tags = _SKIP_CI_TAGS,
225    )
226
227def _skip_test_impl(ctx):
228    _ = ctx  # @unused
229    fail("Should have been skipped")
230
231_skip_test = rule(
232    implementation = _skip_test_impl,
233    test = True,
234)
235
236def _force_exec_config_impl(ctx):
237    return [DefaultInfo(
238        files = depset(ctx.files.tools),
239        default_runfiles = ctx.runfiles().merge_all([
240            t[DefaultInfo].default_runfiles
241            for t in ctx.attr.tools
242        ]),
243        data_runfiles = ctx.runfiles().merge_all([
244            t[DefaultInfo].data_runfiles
245            for t in ctx.attr.tools
246        ]),
247    )]
248
249force_exec_config = rule(
250    implementation = _force_exec_config_impl,
251    doc = "Rule to force arbitrary targets to `cfg=exec` so they can be " +
252          "tested when used as tools.",
253    attrs = {
254        "tools": attr.label_list(
255            cfg = "exec",
256            allow_files = True,
257            doc = "A list of tools to force into the exec config",
258        ),
259    },
260)
261
262util = struct(
263    # keep sorted start
264    empty_file = empty_file,
265    force_exec_config = force_exec_config,
266    helper_target = helper_target,
267    merge_kwargs = merge_kwargs,
268    recursive_testing_aspect = recursive_testing_aspect,
269    runfiles_map = runfiles_map,
270    runfiles_paths = runfiles_paths,
271    short_paths = short_paths,
272    skip_test = skip_test,
273    testing_aspect = testing_aspect,
274    # keep sorted end
275)
276