diff --git a/kotlin/internal/jvm/compile.bzl b/kotlin/internal/jvm/compile.bzl index b5f28b91c..d314c9d7a 100644 --- a/kotlin/internal/jvm/compile.bzl +++ b/kotlin/internal/jvm/compile.bzl @@ -55,6 +55,9 @@ load( "@bazel_skylib//rules:common_settings.bzl", "BuildSettingInfo", ) +load("//kotlin/internal/jvm:kover.bzl", + _is_kover_enabled = "is_kover_enabled" +) # UTILITY ############################################################################################################## @@ -525,7 +528,7 @@ def _run_kt_builder_action( args.add_all("--source_jars", srcs.src_jars + generated_src_jars, omit_if_empty = True) args.add_all("--deps_artifacts", deps_artifacts, omit_if_empty = True) args.add_all("--kotlin_friend_paths", associates.jars, map_each = _associate_utils.flatten_jars) - args.add("--instrument_coverage", ctx.coverage_instrumented()) + args.add("--instrument_coverage", ctx.coverage_instrumented() and not _is_kover_enabled(ctx)) args.add("--track_class_usage", toolchains.kt.experimental_track_class_usage) args.add("--track_resource_usage", toolchains.kt.experimental_track_resource_usage) if ksp_opts: diff --git a/kotlin/internal/jvm/impl.bzl b/kotlin/internal/jvm/impl.bzl index 2ddc98f28..b1fee2934 100644 --- a/kotlin/internal/jvm/impl.bzl +++ b/kotlin/internal/jvm/impl.bzl @@ -31,6 +31,13 @@ load( "//kotlin/internal/utils:utils.bzl", _utils = "utils", ) +load("//kotlin/internal/jvm:kover.bzl", + _is_kover_enabled = "is_kover_enabled", + _get_kover_agent_files = "get_kover_agent_file", + _create_kover_agent_actions = "create_kover_agent_actions", + _create_kover_metadata_action = "create_kover_metadata_action", + _get_kover_jvm_flags = "get_kover_jvm_flags", +) load("//third_party:jarjar.bzl", "jarjar_action") # borrowed from skylib to avoid adding that to the release. @@ -80,7 +87,7 @@ def _write_launcher_action(ctx, rjars, main_class, jvm_flags): if java_runtime.version >= 17: jvm_flags = jvm_flags + " -Djava.security.manager=allow" - if ctx.configuration.coverage_enabled: + if ctx.configuration.coverage_enabled and not _is_kover_enabled(ctx): jacocorunner = ctx.toolchains[_TOOLCHAIN_TYPE].jacocorunner classpath = ctx.configuration.host_path_separator.join( ["${RUNPATH}%s" % (j.short_path) for j in rjars.to_list() + jacocorunner.files.to_list()], @@ -276,12 +283,30 @@ _SPLIT_STRINGS = [ def kt_jvm_junit_test_impl(ctx): providers = _kt_jvm_produce_jar_actions(ctx, "kt_jvm_test") - runtime_jars = depset(ctx.files._bazel_test_runner, transitive = [providers.java.transitive_runtime_jars]) coverage_runfiles = [] + coverage_inputs = [] + coverage_jvm_flags = [] + if ctx.configuration.coverage_enabled: - jacocorunner = ctx.toolchains[_TOOLCHAIN_TYPE].jacocorunner - coverage_runfiles = jacocorunner.files.to_list() + if _is_kover_enabled(ctx): + kover_agent_files = _get_kover_agent_files(ctx) + kover_output_file, kover_args_file = _create_kover_agent_actions(ctx, ctx.attr.name) + kover_output_metadata_file = _create_kover_metadata_action( + ctx, + ctx.attr.name, + ctx.attr.deps + ctx.attr.associates, + kover_output_file + ) + flags = _get_kover_jvm_flags(kover_agent_files, kover_args_file) + + # add Kover agent jvm_flag, inputs and outputs + coverage_jvm_flags = [flags] + coverage_inputs = [depset(kover_agent_files)] + coverage_runfiles = [kover_args_file, kover_output_metadata_file] + else: + jacocorunner = ctx.toolchains[_TOOLCHAIN_TYPE].jacocorunner + coverage_runfiles = jacocorunner.files.to_list() test_class = ctx.attr.test_class @@ -299,8 +324,13 @@ def kt_jvm_junit_test_impl(ctx): jvm_flags = [] if hasattr(ctx.fragments.java, "default_jvm_opts"): jvm_flags = ctx.fragments.java.default_jvm_opts + jvm_flags.extend(coverage_jvm_flags + ctx.attr.jvm_flags) + + runtime_jars = depset( + ctx.files._bazel_test_runner, + transitive = [providers.java.transitive_runtime_jars] + coverage_inputs + ) - jvm_flags.extend(ctx.attr.jvm_flags) coverage_metadata = _write_launcher_action( ctx, runtime_jars, diff --git a/kotlin/internal/jvm/kover.bzl b/kotlin/internal/jvm/kover.bzl new file mode 100644 index 000000000..4c32cfea2 --- /dev/null +++ b/kotlin/internal/jvm/kover.bzl @@ -0,0 +1,164 @@ +# Copyright 2018 The Bazel Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file contains logic to integrate with Kover for code coverage, using +# Kover JVM agent and disabling JaCoCo instrumentation, which avoid having to +# re-compile application code. It used from both JVM and Android kotlin tests. +# +# +# How to use? +# +# Supply the version of Kover agent via toolchain (typically from jvm_rules_extrenal), +# and enable Kover. Then run `bazel coverage //your/kotlin/test_target`. Output files +# are created in working module directory (along test/library explicity outputs). +# +# +# Notes : +# +# 1. Because Bazel test/coverage are 'terminal' and actions or aspects can't reuse the output +# of these, the generation of the report is done outside bazel (typically +# from Bazel wrapper). The logic here will generate both raw output (*.ic file) and +# a metadata file ready to provide to Kover CLI, so that one can generate report simply by +# running : `java -jar kover-cli.jar report @path_to_metadat_file ` +# +# We could possibly generate the report by hijacking test runner shell script template +# and injecting this command to executed after tests are run. This is rather hacky +# and is likely to require changes to Bazel project. +# +# 2. For mixed sourceset, disabling JaCoCo instrumenation is required. To do this properly, +# one should add an extra parameter to java_common.compile() API, which require modifying both +# rules_java and Bazel core. For now, we disabled JaCoCo instrumentation accross the board, +# you will need to cherry-pick this PR https://github.com/uber-common/bazel/commit/cb9f6f042c64af96bbd77e21fe6fb75936c74f47 +# +# 3. Code in `kt_android_local_test_impl.bzl` needs to be kept in sync with rules_android. There is ongoing +# conversation with google to simply of to extend rules_android, and override pipeline's behavior without +# duplicating their code, we should be able to simplify this soon. +# + +load( + "//kotlin/internal:defs.bzl", + _KtJvmInfo = "KtJvmInfo", + _TOOLCHAIN_TYPE = "TOOLCHAIN_TYPE", +) +load("@bazel_skylib//lib:paths.bzl", + _paths = "paths", +) + +def is_kover_enabled(ctx): + return ctx.toolchains[_TOOLCHAIN_TYPE].experimental_kover_enabled + +def get_kover_agent_file(ctx): + """ Get the Kover agent runtime files, extracted from toolchain. + + returns: + the Kover agent runtime files + """ + + kover_agent = ctx.toolchains[_TOOLCHAIN_TYPE].experimental_kover_agent + if not kover_agent: + fail("Kover agent wasn't specified in toolchain.") + + kover_agent_info = kover_agent[DefaultInfo] + return kover_agent_info.files.to_list() + +def get_kover_jvm_flags(kover_agent_files, kover_args_file): + """ Compute the jvm flag used to setup Kover agent. + + returns: + the flag string to be used by test runner jvm + """ + + return "-javaagent:%s=file:%s" % (kover_agent_files[0].short_path, kover_args_file.short_path) + +def create_kover_agent_actions(ctx, name): + """ Generate the actions needed to emit Kover code coverage metadata file. It creates + the properly populated arguments input file needed by Kover agent. + + returns: + the kover metadata output file. + the kover arguments file. + """ + + # declare code coverage raw data binary output file + binary_output_name = "%s-kover_report.ic" % name + kover_output_file = ctx.actions.declare_file(binary_output_name) + + # Hack: there is curently no way to indicate this file will be created Kover agent + ctx.actions.run_shell( + outputs = [kover_output_file], + command = "touch {}".format(kover_output_file.path), + ) + + # declare args file - https://kotlin.github.io/kotlinx-kover/jvm-agent/#kover-jvm-arguments-file + kover_args_file = ctx.actions.declare_file( + "%s-kover.args.txt" % name, + ) + ctx.actions.write( + kover_args_file, + "report.file=../../%s" % binary_output_name # Kotlin compiler runs in runfiles folder, make sure file is created is correct location + ) + + return kover_output_file, kover_args_file + + +def create_kover_metadata_action( + ctx, + name, + deps, + kover_output_file): + """ Generate kover metadata file needed for invoking kover CLI to generate report. + More info at: https://kotlin.github.io/kotlinx-kover/cli/ + + returns: + the kover output metadata file. + """ + + metadata_output_name = "%s-kover_metadata.txt" % name + kover_output_metadata_file = ctx.actions.declare_file(metadata_output_name) + + srcs = [] + classfiles = [] + excludes = [] + + for dep in deps: + if dep.label.package != ctx.label.package: + continue + + if InstrumentedFilesInfo in dep: + for src in dep[InstrumentedFilesInfo].instrumented_files.to_list(): + if src.short_path.startswith(ctx.label.package + "/"): + path = _paths.dirname(src.short_path) + if path not in srcs: + srcs.extend(["--src", path]) + + if JavaInfo in dep: + for classfile in dep[JavaInfo].transitive_runtime_jars.to_list(): + if classfile.short_path.startswith(ctx.label.package + "/"): + if classfile.path not in classfiles: + classfiles.extend(["--classfiles", classfile.path]) + + for exclude in ctx.toolchains[_TOOLCHAIN_TYPE].experimental_kover_exclude: + excludes.extend(["--exclude", exclude]) + + for exclude_annotation in ctx.toolchains[_TOOLCHAIN_TYPE].experimental_kover_exclude_annotation: + excludes.extend(["--excludeAnnotation", exclude_annotation]) + + ctx.actions.write(kover_output_metadata_file, "\n".join([ + "report", + kover_output_file.path, + "--title", + "Code-Coverage Analysis: %s" % ctx.label, + ] + srcs + classfiles + excludes)) + + return kover_output_metadata_file diff --git a/kotlin/internal/jvm/kt_android_local_test_impl.bzl b/kotlin/internal/jvm/kt_android_local_test_impl.bzl index 1136fa349..15595d547 100644 --- a/kotlin/internal/jvm/kt_android_local_test_impl.bzl +++ b/kotlin/internal/jvm/kt_android_local_test_impl.bzl @@ -19,6 +19,9 @@ load( "@rules_android//rules:attrs.bzl", _attrs = "attrs", ) +load("@rules_android//rules:common.bzl", + _common = "common", +) load( "@rules_android//rules:java.bzl", _java = "java", @@ -46,12 +49,16 @@ load( load( "@rules_android//rules/android_local_test:impl.bzl", _BASE_PROCESSORS = "PROCESSORS", + _DEFAULT_VERIFY_FLAGS = "DEFAULT_VERIFY_FLAGS", + _DEFAULT_JIT_FLAGS = "DEFAULT_JIT_FLAGS", + _DEFAULT_GC_FLAGS = "DEFAULT_GC_FLAGS", _filter_jdeps = "filter_jdeps", _finalize = "finalize", ) load( "//kotlin/internal:defs.bzl", _JAVA_RUNTIME_TOOLCHAIN_TYPE = "JAVA_RUNTIME_TOOLCHAIN_TYPE", + _KtJvmInfo = "KtJvmInfo", _TOOLCHAIN_TYPE = "TOOLCHAIN_TYPE", ) load( @@ -60,6 +67,14 @@ load( _export_only_providers = "export_only_providers", _kt_jvm_produce_output_jar_actions = "kt_jvm_produce_output_jar_actions", ) +load("//kotlin/internal/jvm:kover.bzl", + _is_kover_enabled = "is_kover_enabled", + _get_kover_agent_files = "get_kover_agent_file", + _create_kover_agent_actions = "create_kover_agent_actions", + _create_kover_metadata_action = "create_kover_metadata_action", + _get_kover_jvm_flags = "get_kover_jvm_flags", +) +load("@bazel_skylib//lib:paths.bzl", "paths") JACOCOCO_CLASS = "com.google.testing.coverage.JacocoCoverageRunner" @@ -114,10 +129,31 @@ def _process_jvm(ctx, resources_ctx, **unused_sub_ctxs): getattr(ctx.attr, "deps", []) ) + jvm_flags = [] + transitive = [] + if ctx.configuration.coverage_enabled: - deps.append(ctx.toolchains[_TOOLCHAIN_TYPE].jacocorunner) - java_start_class = JACOCOCO_CLASS - coverage_start_class = ctx.attr.main_class + if _is_kover_enabled(ctx): + kover_agent_files = _get_kover_agent_files(ctx) + kover_output_file, kover_args_file = _create_kover_agent_actions(ctx, ctx.attr.name) + kover_output_metadata_file = _create_kover_metadata_action( + ctx, + ctx.attr.name, + ctx.attr.deps + ctx.attr.associates, + kover_output_file + ) + + flags = _get_kover_jvm_flags(kover_agent_files, kover_args_file) + jvm_flags.append(flags) + + transitive.extend([depset(kover_agent_files), depset([kover_args_file]), depset([kover_output_metadata_file])]) + + java_start_class = ctx.attr.main_class + coverage_start_class = None + else: + deps.append(ctx.toolchains[_TOOLCHAIN_TYPE].jacocorunner) + java_start_class = JACOCOCO_CLASS + coverage_start_class = ctx.attr.main_class else: java_start_class = ctx.attr.main_class coverage_start_class = None @@ -161,7 +197,6 @@ def _process_jvm(ctx, resources_ctx, **unused_sub_ctxs): runfiles.append(filtered_jdeps) # Append the security manager override - jvm_flags = [] java_runtime = ctx.toolchains[_JAVA_RUNTIME_TOOLCHAIN_TYPE].java_runtime if java_runtime.version >= 17: jvm_flags.append("-Djava.security.manager=allow") @@ -177,13 +212,167 @@ def _process_jvm(ctx, resources_ctx, **unused_sub_ctxs): android_properties_file = ctx.file.robolectric_properties_file.short_path, additional_jvm_flags = jvm_flags, ), - runfiles = ctx.runfiles(files = runfiles), + runfiles = ctx.runfiles( + files = runfiles, + transitive_files = depset(transitive = transitive) + ), + ) + +# TODO: follow up with Google to have rules_android provide better extensibility points +def _process_stub(ctx, deploy_jar_ctx, jvm_ctx, stub_preprocess_ctx, **_unused_sub_ctxs): + runfiles = [] + + merged_instr = None + if ctx.configuration.coverage_enabled: + if not _is_kover_enabled(ctx): + merged_instr = ctx.actions.declare_file(ctx.label.name + "_merged_instr.jar") + _java.singlejar( + ctx, + [f for f in deploy_jar_ctx.classpath.to_list() if f.short_path.endswith(".jar")], + merged_instr, + mnemonic = "JavaDeployJar", + include_build_data = True, + java_toolchain = _common.get_java_toolchain(ctx), + ) + runfiles.append(merged_instr) + + stub = ctx.actions.declare_file(ctx.label.name) + classpath_file = ctx.actions.declare_file(ctx.label.name + "_classpath") + runfiles.append(classpath_file) + test_class = _get_test_class(ctx) + if not test_class: + fail("test_class could not be derived for " + str(ctx.label) + + ". Explicitly set test_class or move this source file to " + + "a java source root.") + + _create_stub( + ctx, + stub_preprocess_ctx.substitutes, + stub, + classpath_file, + deploy_jar_ctx.classpath, + _get_jvm_flags(ctx, test_class, jvm_ctx.android_properties_file, jvm_ctx.additional_jvm_flags), + jvm_ctx.java_start_class, + jvm_ctx.coverage_start_class, + merged_instr, + ) + return _ProviderInfo( + name = "stub_ctx", + value = struct( + stub = stub, + ), + runfiles = ctx.runfiles( + files = runfiles, + transitive_files = depset( + transitive = stub_preprocess_ctx.runfiles, + ), + ), ) +def _get_test_class(ctx): + # Use the specified test_class if set + if ctx.attr.test_class != "": + return ctx.attr.test_class + + # Use a heuristic based on the rule name and the "srcs" list + # to determine the primary Java class. + expected = "/" + ctx.label.name + ".java" + for f in ctx.attr.srcs: + path = f.label.package + "/" + f.label.name + if path.endswith(expected): + return _java.resolve_package(path[:-5]) + + # Last resort: Use the name and package name of the target. + return _java.resolve_package(ctx.label.package + "/" + ctx.label.name) + +def _create_stub( + ctx, + substitutes, + stub_file, + classpath_file, + runfiles, + jvm_flags, + java_start_class, + coverage_start_class, + merged_instr): + subs = { + "%needs_runfiles%": "1", + "%runfiles_manifest_only%": "", + # To avoid cracking open the depset, classpath is read from a separate + # file created in its own action. Needed as expand_template does not + # support ctx.actions.args(). + "%classpath%": "$(eval echo $(<%s))" % (classpath_file.short_path), + "%java_start_class%": java_start_class, + "%jvm_flags%": " ".join(jvm_flags), + "%workspace_prefix%": ctx.workspace_name + "/", + } + + if coverage_start_class: + prefix = ctx.attr._runfiles_root_prefix[_BuildSettingInfo].value + subs["%set_jacoco_metadata%"] = ( + "export JACOCO_METADATA_JAR=${JAVA_RUNFILES}/" + prefix + + merged_instr.short_path + ) + subs["%set_jacoco_main_class%"] = ( + "export JACOCO_MAIN_CLASS=" + coverage_start_class + ) + subs["%set_jacoco_java_runfiles_root%"] = ( + "export JACOCO_JAVA_RUNFILES_ROOT=${JAVA_RUNFILES}/" + prefix + ) + else: + subs["%set_jacoco_metadata%"] = "" + subs["%set_jacoco_main_class%"] = "" + subs["%set_jacoco_java_runfiles_root%"] = "" + + subs.update(substitutes) + + ctx.actions.expand_template( + template = _utils.only(_get_android_toolchain(ctx).java_stub.files.to_list()), + output = stub_file, + substitutions = subs, + is_executable = True, + ) + + args = ctx.actions.args() + args.add_joined( + runfiles, + join_with = ":", + map_each = _get_classpath, + ) + args.set_param_file_format("multiline") + ctx.actions.write( + output = classpath_file, + content = args, + ) + return stub_file + +def _get_classpath(s): + return "${J3}" + s.short_path + +def _get_jvm_flags(ctx, main_class, robolectric_properties_path, additional_jvm_flags): + return [ + "-ea", + "-Dbazel.test_suite=" + main_class, + "-Drobolectric.offline=true", + "-Drobolectric-deps.properties=" + robolectric_properties_path, + "-Duse_framework_manifest_parser=true", + "-Drobolectric.logging=stdout", + "-Drobolectric.logging.enabled=true", + "-Dorg.robolectric.packagesToNotAcquire=com.google.testing.junit.runner.util", + ] + _DEFAULT_JIT_FLAGS + _DEFAULT_GC_FLAGS + _DEFAULT_VERIFY_FLAGS + additional_jvm_flags + [ + ctx.expand_make_variables( + "jvm_flags", + ctx.expand_location(flag, ctx.attr.data), + {}, + ) + for flag in ctx.attr.jvm_flags + ] + PROCESSORS = _processing_pipeline.replace( _BASE_PROCESSORS, ResourceProcessor = _process_resources, JvmProcessor = _process_jvm, + StubProcessor = _process_stub, ) _PROCESSING_PIPELINE = _processing_pipeline.make_processing_pipeline( diff --git a/kotlin/internal/toolchains.bzl b/kotlin/internal/toolchains.bzl index f28030682..ce1423447 100644 --- a/kotlin/internal/toolchains.bzl +++ b/kotlin/internal/toolchains.bzl @@ -95,6 +95,10 @@ def _kotlin_toolchain_impl(ctx): empty_jar = ctx.file._empty_jar, empty_jdeps = ctx.file._empty_jdeps, jacocorunner = ctx.attr.jacocorunner, + experimental_kover_enabled = ctx.attr.experimental_kover_enabled, + experimental_kover_agent = ctx.attr.experimental_kover_agent, + experimental_kover_exclude = ctx.attr.experimental_kover_exclude, + experimental_kover_exclude_annotation = ctx.attr.experimental_kover_exclude_annotation, experimental_prune_transitive_deps = ctx.attr._experimental_prune_transitive_deps[BuildSettingInfo].value, ) @@ -293,6 +297,20 @@ _kt_toolchain = rule( "jacocorunner": attr.label( default = Label("@bazel_tools//tools/jdk:JacocoCoverage"), ), + "experimental_kover_enabled": attr.bool( + doc = """Use kover for code coverage.""", + default = False, + ), + "experimental_kover_agent": attr.label( + doc = """Kover agent Jar target used for code coverage, only used if experimental_kover_enabled is true.""", + providers = [JavaInfo], + ), + "experimental_kover_exclude": attr.string_list( + doc = """List of exclusions to use when generating kover reports.""", + ), + "experimental_kover_exclude_annotation": attr.string_list( + doc = """List of annotation exclusions to use when generating kover reports.""", + ), "_experimental_prune_transitive_deps": attr.label( doc = """If enabled, compilation is performed against only direct dependencies. Transitive deps required for compilation must be explicitly added. Using @@ -337,6 +355,10 @@ def define_kt_toolchain( experimental_track_resource_usage = None, experimental_compile_with_transitive_deps = True, experimental_multiplex_workers = None, + experimental_kover_enabled = False, + experimental_kover_agent = None, + experimental_kover_exclude = [], + experimental_kover_exclude_annotation = [], javac_options = Label("//kotlin/internal:default_javac_options"), kotlinc_options = Label("//kotlin/internal:default_kotlinc_options"), jacocorunner = None): @@ -361,6 +383,10 @@ def define_kt_toolchain( experimental_track_class_usage = experimental_track_class_usage, experimental_track_resource_usage = experimental_track_resource_usage, experimental_compile_with_transitive_deps = experimental_compile_with_transitive_deps, + experimental_kover_enabled = experimental_kover_enabled, + experimental_kover_agent = experimental_kover_agent, + experimental_kover_exclude = experimental_kover_exclude, + experimental_kover_exclude_annotation = experimental_kover_exclude_annotation, javac_options = javac_options, kotlinc_options = kotlinc_options, visibility = ["//visibility:public"],