From 3cda232fba4b7e802097c78fe3422d1b61b9e6db Mon Sep 17 00:00:00 2001 From: Tom Dyas Date: Sun, 21 Aug 2022 00:37:42 -0400 Subject: [PATCH] go: add support for test coverage (#16550) Update the Go backend to gather code coverage data for packages under test. The coverage files are written under `dist/coverage/go/IMPORT_PATH` directories (where `IMPORT_PATH` has slashes escaped to underscores). The coverage output is the raw `cover.out` format from the `go` tooling and is not rendered by this PR (which can be done in a follow-on since it is a bit involved). [ci skip-rust] [ci skip-build-wheels] --- .../pants/backend/experimental/go/register.py | 4 + .../go/go_sources/generate_testmain/main.go | 17 +- src/python/pants/backend/go/goals/test.py | 103 ++++++- .../pants/backend/go/subsystems/gotest.py | 41 ++- .../pants/backend/go/util_rules/build_pkg.py | 65 ++++- .../backend/go/util_rules/build_pkg_target.py | 3 + .../pants/backend/go/util_rules/coverage.py | 274 ++++++++++++++++++ .../backend/go/util_rules/coverage_output.py | 81 ++++++ .../backend/go/util_rules/coverage_test.py | 118 ++++++++ .../backend/go/util_rules/tests_analysis.py | 7 + .../go/util_rules/tests_analysis_test.py | 55 ++-- 11 files changed, 719 insertions(+), 49 deletions(-) create mode 100644 src/python/pants/backend/go/util_rules/coverage.py create mode 100644 src/python/pants/backend/go/util_rules/coverage_output.py create mode 100644 src/python/pants/backend/go/util_rules/coverage_test.py diff --git a/src/python/pants/backend/experimental/go/register.py b/src/python/pants/backend/experimental/go/register.py index 5b944af8bb53..fae48818dad3 100644 --- a/src/python/pants/backend/experimental/go/register.py +++ b/src/python/pants/backend/experimental/go/register.py @@ -16,6 +16,8 @@ assembly, build_pkg, build_pkg_target, + coverage, + coverage_output, first_party_pkg, go_bootstrap, go_mod, @@ -39,6 +41,8 @@ def rules(): *build_pkg.rules(), *build_pkg_target.rules(), *check.rules(), + *coverage.rules(), + *coverage_output.rules(), *third_party_pkg.rules(), *go_bootstrap.rules(), *goroot.rules(), diff --git a/src/python/pants/backend/go/go_sources/generate_testmain/main.go b/src/python/pants/backend/go/go_sources/generate_testmain/main.go index f6ebfe0b0dbf..86a8fd76f51a 100644 --- a/src/python/pants/backend/go/go_sources/generate_testmain/main.go +++ b/src/python/pants/backend/go/go_sources/generate_testmain/main.go @@ -80,6 +80,10 @@ type Analysis struct { // True if Go 1.18 is in use. This is not set by analyze but rather by generate based on release tags. IsGo1_18 bool + + // True if coverage is enabled. This is not set by `analyze` but rather by `generate` based on an + // environment variable set by the invoker. + Cover bool } // isTestFunc tells whether fn has the type of a testing function. arg @@ -323,6 +327,10 @@ func init() { } func main() { +{{if .Cover}} + registerCover() +{{end}} + {{- if .IsGo1_18 }} m := testing.MainStart(testdeps.TestDeps{}, tests, benchmarks, fuzzTargets, examples) {{- else }} @@ -337,7 +345,7 @@ func main() { } ` -func generate(analysis *Analysis) ([]byte, error) { +func generate(analysis *Analysis, genCover bool) ([]byte, error) { tmpl, err := template.New("testmain").Parse(testMainTemplate) if err != nil { return nil, err @@ -351,6 +359,9 @@ func generate(analysis *Analysis) ([]byte, error) { } } + // Pass through the config to generate the call to the coverage stubs. + analysis.Cover = genCover + var buffer bytes.Buffer err = tmpl.Execute(&buffer, analysis) @@ -368,7 +379,9 @@ func main() { os.Exit(1) } - testmain, err := generate(analysis) + genCover := os.Getenv("GENERATE_COVER") != "" + + testmain, err := generate(analysis, genCover) if err != nil { fmt.Fprintf(os.Stderr, "Failed to generate _testmain.go: %s\n", err) os.Exit(1) diff --git a/src/python/pants/backend/go/goals/test.py b/src/python/pants/backend/go/goals/test.py index 230d649baccd..0fd98cabd87e 100644 --- a/src/python/pants/backend/go/goals/test.py +++ b/src/python/pants/backend/go/goals/test.py @@ -16,10 +16,17 @@ ) from pants.backend.go.util_rules.build_pkg import ( BuildGoPackageRequest, + BuiltGoPackage, FallibleBuildGoPackageRequest, FallibleBuiltGoPackage, ) from pants.backend.go.util_rules.build_pkg_target import BuildGoPackageTargetRequest +from pants.backend.go.util_rules.coverage import ( + GenerateCoverageSetupCodeRequest, + GenerateCoverageSetupCodeResult, + GoCoverageConfig, + GoCoverageData, +) from pants.backend.go.util_rules.first_party_pkg import ( FallibleFirstPartyPkgAnalysis, FallibleFirstPartyPkgDigest, @@ -41,6 +48,7 @@ from pants.core.util_rules.source_files import SourceFiles, SourceFilesRequest from pants.engine.environment import Environment, EnvironmentRequest from pants.engine.fs import EMPTY_FILE_DIGEST, AddPrefix, Digest, MergeDigests +from pants.engine.internals.native_engine import EMPTY_DIGEST from pants.engine.process import FallibleProcessResult, Process, ProcessCacheScope from pants.engine.rules import Get, MultiGet, collect_rules, rule from pants.engine.target import Dependencies, DependenciesRequest, SourcesField, Target, Targets @@ -189,17 +197,18 @@ def compilation_failure(exit_code: int, stdout: str | None, stderr: str | None) testmain = await Get( GeneratedTestMain, GenerateTestMainRequest( - pkg_digest.digest, - FrozenOrderedSet( + digest=pkg_digest.digest, + test_paths=FrozenOrderedSet( os.path.join(".", pkg_analysis.dir_path, name) for name in pkg_analysis.test_go_files ), - FrozenOrderedSet( + xtest_paths=FrozenOrderedSet( os.path.join(".", pkg_analysis.dir_path, name) for name in pkg_analysis.xtest_go_files ), - import_path, - field_set.address, + import_path=import_path, + register_cover=test_subsystem.use_coverage, + address=field_set.address, ), ) @@ -210,10 +219,16 @@ def compilation_failure(exit_code: int, stdout: str | None, stderr: str | None) if not testmain.has_tests and not testmain.has_xtests: return TestResult.skip(field_set.address, output_setting=test_subsystem.output) + coverage_config: GoCoverageConfig | None = None + if test_subsystem.use_coverage: + coverage_config = GoCoverageConfig(cover_mode=go_test_subsystem.coverage_mode) + # Construct the build request for the package under test. maybe_test_pkg_build_request = await Get( FallibleBuildGoPackageRequest, - BuildGoPackageTargetRequest(field_set.address, for_tests=True), + BuildGoPackageTargetRequest( + field_set.address, for_tests=True, coverage_config=coverage_config + ), ) if maybe_test_pkg_build_request.request is None: assert maybe_test_pkg_build_request.stderr is not None @@ -222,6 +237,8 @@ def compilation_failure(exit_code: int, stdout: str | None, stderr: str | None) ) test_pkg_build_request = maybe_test_pkg_build_request.request + # TODO: Eventually support adding coverage to non-test packages. Those other packages will need to be + # added to `main_direct_deps` and to the coverage setup in the testmain. main_direct_deps = [test_pkg_build_request] if testmain.has_xtests: @@ -251,17 +268,48 @@ def compilation_failure(exit_code: int, stdout: str | None, stderr: str | None) direct_dependencies=tuple(direct_dependencies), minimum_go_version=pkg_analysis.minimum_go_version, embed_config=pkg_digest.xtest_embed_config, + coverage_config=coverage_config, ) main_direct_deps.append(xtest_pkg_build_request) + # Generate coverage setup code for the test main if coverage is enabled. + # + # Note: Go coverage analysis is a form of codegen. It rewrites the Go source code at issue to include explicit + # references to "coverage variables" which contain the statement counts for coverage analysis. The test main + # generated for a Go test binary has to explicitly reference the coverage variables generated by this codegen and + # register them with the coverage runtime. + coverage_setup_digest = EMPTY_DIGEST + coverage_setup_files = [] + if coverage_config is not None: + # Build the `main_direct_deps` when in coverage mode to obtain the "coverage variables" for those packages. + built_main_direct_deps = await MultiGet( + Get(BuiltGoPackage, BuildGoPackageRequest, build_req) for build_req in main_direct_deps + ) + coverage_metadata = [ + pkg.coverage_metadata for pkg in built_main_direct_deps if pkg.coverage_metadata + ] + coverage_setup_result = await Get( + GenerateCoverageSetupCodeResult, + GenerateCoverageSetupCodeRequest( + packages=FrozenOrderedSet(coverage_metadata), + cover_mode=go_test_subsystem.coverage_mode, + ), + ) + coverage_setup_digest = coverage_setup_result.digest + coverage_setup_files = [GenerateCoverageSetupCodeResult.PATH] + + testmain_input_digest = await Get( + Digest, MergeDigests([testmain.digest, coverage_setup_digest]) + ) + # Generate the synthetic main package which imports the test and/or xtest packages. maybe_built_main_pkg = await Get( FallibleBuiltGoPackage, BuildGoPackageRequest( import_path="main", - digest=testmain.digest, + digest=testmain_input_digest, dir_path="", - go_file_names=(GeneratedTestMain.TEST_MAIN_FILE,), + go_file_names=(GeneratedTestMain.TEST_MAIN_FILE, *coverage_setup_files), s_file_names=(), direct_dependencies=tuple(main_direct_deps), minimum_go_version=pkg_analysis.minimum_go_version, @@ -326,25 +374,48 @@ def compilation_failure(exit_code: int, stdout: str | None, stderr: str | None) ProcessCacheScope.PER_SESSION if test_subsystem.force else ProcessCacheScope.SUCCESSFUL ) + maybe_cover_args = [] + maybe_cover_output_file = [] + if test_subsystem.use_coverage: + maybe_cover_args = ["-test.coverprofile=cover.out"] + maybe_cover_output_file = ["cover.out"] + + test_run_args = [ + "./test_runner", + *transform_test_args( + go_test_subsystem.args, + field_set.timeout.calculate_from_global_options(test_subsystem), + ), + *maybe_cover_args, + ] + result = await Get( FallibleProcessResult, Process( - [ - "./test_runner", - *transform_test_args( - go_test_subsystem.args, - field_set.timeout.calculate_from_global_options(test_subsystem), - ), - ], + argv=test_run_args, env=extra_env, input_digest=test_input_digest, description=f"Run Go tests: {field_set.address}", cache_scope=cache_scope, working_directory=working_dir, + output_files=maybe_cover_output_file, level=LogLevel.DEBUG, ), ) - return TestResult.from_fallible_process_result(result, field_set.address, test_subsystem.output) + + coverage_data: GoCoverageData | None = None + if test_subsystem.use_coverage: + coverage_data = GoCoverageData( + coverage_digest=result.output_digest, + import_path=import_path, + ) + + return TestResult.from_fallible_process_result( + process_result=result, + address=field_set.address, + output_setting=test_subsystem.output, + coverage_data=coverage_data, + ) @rule diff --git a/src/python/pants/backend/go/subsystems/gotest.py b/src/python/pants/backend/go/subsystems/gotest.py index 07627323c14d..eb52dc9499c0 100644 --- a/src/python/pants/backend/go/subsystems/gotest.py +++ b/src/python/pants/backend/go/subsystems/gotest.py @@ -3,7 +3,11 @@ from __future__ import annotations -from pants.option.option_types import ArgsListOption +from pathlib import PurePath + +from pants.backend.go.util_rules.coverage import GoCoverMode +from pants.core.util_rules.distdir import DistDir +from pants.option.option_types import ArgsListOption, EnumOption, StrOption from pants.option.subsystem import Subsystem from pants.util.strutil import softwrap @@ -24,3 +28,38 @@ class GoTestSubsystem(Subsystem): ), passthrough=True, ) + + coverage_mode = EnumOption( + "--cover-mode", + default=GoCoverMode.SET, + help=softwrap( + """\ + Coverage mode to use when running Go tests with coverage analysis enabled via --test-use-coverage. + Valid values are `set`, `count`, and `atomic`:\n + * `set`: bool: does this statement run?\n + * `count`: int: how many times does this statement run?\n + * `atomic`: int: count, but correct in multithreaded tests; significantly more expensive.\n + """ + ), + ) + + _coverage_output_dir = StrOption( + default=str(PurePath("{distdir}", "coverage", "go", "{import_path_escaped}")), + advanced=True, + help=softwrap( + """ + Path to write the Go coverage reports to. Must be relative to the build root. + `{distdir}` is replaced with the Pants `distdir`, and `{import_path_escaped}` is + replaced with the applicable package's import path but with slashes converted to + underscores. + """ + ), + ) + + def coverage_output_dir(self, distdir: DistDir, import_path: str) -> PurePath: + import_path_escaped = import_path.replace("/", "_") + return PurePath( + self._coverage_output_dir.format( + distdir=distdir.relpath, import_path_escaped=import_path_escaped + ) + ) diff --git a/src/python/pants/backend/go/util_rules/build_pkg.py b/src/python/pants/backend/go/util_rules/build_pkg.py index c7b1a6d9d125..a04a056800d8 100644 --- a/src/python/pants/backend/go/util_rules/build_pkg.py +++ b/src/python/pants/backend/go/util_rules/build_pkg.py @@ -7,12 +7,20 @@ import os.path from dataclasses import dataclass +from pants.backend.go.util_rules import coverage from pants.backend.go.util_rules.assembly import ( AssemblyPostCompilation, AssemblyPostCompilationRequest, AssemblyPreCompilationRequest, FallibleAssemblyPreCompilation, ) +from pants.backend.go.util_rules.coverage import ( + ApplyCodeCoverageRequest, + ApplyCodeCoverageResult, + BuiltGoPackageCodeCoverageMetadata, + FileCodeCoverageMetadata, + GoCoverageConfig, +) from pants.backend.go.util_rules.embedcfg import EmbedConfig from pants.backend.go.util_rules.goroot import GoRoot from pants.backend.go.util_rules.import_analysis import ImportConfig, ImportConfigRequest @@ -39,6 +47,7 @@ def __init__( minimum_go_version: str | None, for_tests: bool = False, embed_config: EmbedConfig | None = None, + coverage_config: GoCoverageConfig | None = None, ) -> None: """Build a package and its dependencies as `__pkg__.a` files. @@ -55,6 +64,7 @@ def __init__( self.minimum_go_version = minimum_go_version self.for_tests = for_tests self.embed_config = embed_config + self.coverage_config = coverage_config self._hashcode = hash( ( self.import_path, @@ -66,6 +76,7 @@ def __init__( self.minimum_go_version, self.for_tests, self.embed_config, + self.coverage_config, ) ) @@ -82,7 +93,8 @@ def __repr__(self) -> str: f"direct_dependencies={[dep.import_path for dep in self.direct_dependencies]}, " f"minimum_go_version={self.minimum_go_version}, " f"for_tests={self.for_tests}, " - f"embed_config={self.embed_config}" + f"embed_config={self.embed_config}, " + f"coverage_config={self.coverage_config}" ")" ) @@ -102,6 +114,7 @@ def __eq__(self, other): and self.minimum_go_version == other.minimum_go_version and self.for_tests == other.for_tests and self.embed_config == other.embed_config + and self.coverage_config == other.coverage_config # TODO: Use a recursive memoized __eq__ if this ever shows up in profiles. and self.direct_dependencies == other.direct_dependencies ) @@ -184,6 +197,7 @@ class BuiltGoPackage: digest: Digest import_paths_to_pkg_a_files: FrozenDict[str, str] + coverage_metadata: BuiltGoPackageCodeCoverageMetadata | None = None @dataclass(frozen=True) @@ -236,9 +250,34 @@ async def build_go_package( Get(GoCompileActionIdResult, GoCompileActionIdRequest(request)), ) + # If coverage is enabled for this package, then replace the Go source files with versions modified to + # contain coverage code. + # Note: When cgo is implemented, coverage should be applied *after* cgo processing. + go_file_names = request.go_file_names + go_files_digest = request.digest + cover_file_metadatas: tuple[FileCodeCoverageMetadata, ...] | None = None + if request.coverage_config: + coverage_result = await Get( + ApplyCodeCoverageResult, + ApplyCodeCoverageRequest( + digest=request.digest, + dir_path=request.dir_path, + go_files=go_file_names, + cover_mode=request.coverage_config.cover_mode, + import_path=request.import_path, + ), + ) + go_files_digest = await Get(Digest, MergeDigests([go_files_digest, coverage_result.digest])) + cover_file_metadatas = coverage_result.cover_file_metadatas + new_go_file_names = list(go_file_names) + for cover_file_metadata in cover_file_metadatas: + idx = new_go_file_names.index(cover_file_metadata.go_file) + new_go_file_names[idx] = cover_file_metadata.cover_go_file + go_file_names = tuple(new_go_file_names) + input_digest = await Get( Digest, - MergeDigests([merged_deps_digest, import_config.digest, embedcfg.digest, request.digest]), + MergeDigests([merged_deps_digest, import_config.digest, embedcfg.digest, go_files_digest]), ) assembly_digests = None @@ -298,7 +337,7 @@ async def build_go_package( relativized_sources = ( f"./{request.dir_path}/{name}" if request.dir_path else f"./{name}" - for name in request.go_file_names + for name in go_file_names ) compile_args.extend(["--", *relativized_sources]) compile_result = await Get( @@ -347,7 +386,20 @@ async def build_go_package( output_digest = await Get(Digest, AddPrefix(compilation_digest, path_prefix)) merged_result_digest = await Get(Digest, MergeDigests([*dep_digests, output_digest])) - output = BuiltGoPackage(merged_result_digest, FrozenDict(import_paths_to_pkg_a_files)) + coverage_metadata = ( + BuiltGoPackageCodeCoverageMetadata( + import_path=request.import_path, + cover_file_metadatas=cover_file_metadatas, + ) + if cover_file_metadatas + else None + ) + + output = BuiltGoPackage( + digest=merged_result_digest, + import_paths_to_pkg_a_files=FrozenDict(import_paths_to_pkg_a_files), + coverage_metadata=coverage_metadata, + ) return FallibleBuiltGoPackage(output, request.import_path) @@ -420,4 +472,7 @@ async def compute_compile_action_id( def rules(): - return collect_rules() + return ( + *collect_rules(), + *coverage.rules(), + ) diff --git a/src/python/pants/backend/go/util_rules/build_pkg_target.py b/src/python/pants/backend/go/util_rules/build_pkg_target.py index 2eac281371ca..58f0d30fffec 100644 --- a/src/python/pants/backend/go/util_rules/build_pkg_target.py +++ b/src/python/pants/backend/go/util_rules/build_pkg_target.py @@ -16,6 +16,7 @@ BuildGoPackageRequest, FallibleBuildGoPackageRequest, ) +from pants.backend.go.util_rules.coverage import GoCoverageConfig from pants.backend.go.util_rules.embedcfg import EmbedConfig from pants.backend.go.util_rules.first_party_pkg import ( FallibleFirstPartyPkgAnalysis, @@ -56,6 +57,7 @@ class BuildGoPackageTargetRequest(EngineAwareParameter): address: Address is_main: bool = False for_tests: bool = False + coverage_config: GoCoverageConfig | None = None def debug_hint(self) -> str: return str(self.address) @@ -228,6 +230,7 @@ async def setup_build_go_package_target_request( direct_dependencies=tuple(direct_dependencies), for_tests=request.for_tests, embed_config=embed_config, + coverage_config=request.coverage_config, ) return FallibleBuildGoPackageRequest(result, import_path) diff --git a/src/python/pants/backend/go/util_rules/coverage.py b/src/python/pants/backend/go/util_rules/coverage.py new file mode 100644 index 000000000000..a1cb74cbd94e --- /dev/null +++ b/src/python/pants/backend/go/util_rules/coverage.py @@ -0,0 +1,274 @@ +# Copyright 2022 Pants project contributors (see CONTRIBUTORS.md). +# Licensed under the Apache License, Version 2.0 (see LICENSE). +from __future__ import annotations + +import enum +import hashlib +import os +from dataclasses import dataclass +from pathlib import PurePath + +from pants.backend.go.util_rules.sdk import GoSdkProcess, GoSdkToolIDRequest, GoSdkToolIDResult +from pants.base.glob_match_error_behavior import GlobMatchErrorBehavior +from pants.core.goals.test import CoverageData +from pants.engine.fs import CreateDigest, DigestSubset, FileContent, PathGlobs +from pants.engine.internals.native_engine import Digest, MergeDigests +from pants.engine.internals.selectors import Get, MultiGet +from pants.engine.process import ProcessResult +from pants.engine.rules import collect_rules, rule +from pants.util.ordered_set import FrozenOrderedSet + + +@dataclass(frozen=True) +class GoCoverageData(CoverageData): + coverage_digest: Digest + import_path: str + + +class GoCoverMode(enum.Enum): + SET = "set" + COUNT = "count" + ATOMIC = "atomic" + + +@dataclass(frozen=True) +class GoCoverageConfig: + cover_mode: GoCoverMode + + +@dataclass(frozen=True) +class ApplyCodeCoverageRequest: + """Apply code coverage to a package using `go tool cover`.""" + + digest: Digest + dir_path: str + go_files: tuple[str, ...] + cover_mode: GoCoverMode + import_path: str + + +@dataclass(frozen=True) +class FileCodeCoverageMetadata: + """Metadata for code coverage applied to a single Go file.""" + + file_id: str + go_file: str + cover_go_file: str + cover_var: str + + +@dataclass(frozen=True) +class BuiltGoPackageCodeCoverageMetadata: + import_path: str + cover_file_metadatas: tuple[FileCodeCoverageMetadata, ...] + + +@dataclass(frozen=True) +class ApplyCodeCoverageResult: + digest: Digest + cover_file_metadatas: tuple[FileCodeCoverageMetadata, ...] + + +@dataclass(frozen=True) +class ApplyCodeCoverageToFileRequest: + digest: Digest + go_file: str + cover_go_file: str + mode: GoCoverMode + cover_var: str + + +@dataclass(frozen=True) +class ApplyCodeCoverageToFileResult: + digest: Digest + cover_go_file: str + + +@rule +async def go_apply_code_coverage_to_file( + request: ApplyCodeCoverageToFileRequest, +) -> ApplyCodeCoverageToFileResult: + cover_tool_id = await Get(GoSdkToolIDResult, GoSdkToolIDRequest("cover")) + + result = await Get( + ProcessResult, + GoSdkProcess( + input_digest=request.digest, + command=[ + "tool", + "cover", + "-mode", + request.mode.value, + "-var", + request.cover_var, + "-o", + request.cover_go_file, + request.go_file, + ], + description=f"Apply Go coverage to: {request.go_file}", + output_files=(str(request.cover_go_file),), + env={"__PANTS_GO_COVER_TOOL_ID": cover_tool_id.tool_id}, + ), + ) + + return ApplyCodeCoverageToFileResult( + digest=result.output_digest, + cover_go_file=request.cover_go_file, + ) + + +def _hash_string(s: str) -> str: + h = hashlib.sha256(s.encode()) + return h.hexdigest()[:12] + + +@rule +async def go_apply_code_coverage(request: ApplyCodeCoverageRequest) -> ApplyCodeCoverageResult: + # Code coverage is never applied to test files. + go_files = [go_file for go_file in request.go_files if not go_file.endswith("_test.go")] + + subsetted_digests = await MultiGet( + Get( + Digest, + DigestSubset( + request.digest, + PathGlobs( + [os.path.join(request.dir_path, go_file)], + glob_match_error_behavior=GlobMatchErrorBehavior.error, + description_of_origin="coverage", + ), + ), + ) + for go_file in go_files + ) + + # Setup metadata for each file to which code coverage will be applied by assigning the name of the exported + # variable which holds coverage counters for each file. + import_path_hash = _hash_string(request.import_path) + file_metadatas = [] + for i, go_file in enumerate(go_files): + p = PurePath(go_file) + file_metadatas.append( + FileCodeCoverageMetadata( + file_id=f"{request.import_path}/{go_file}", + go_file=go_file, + cover_go_file=str(p.with_name(f"{p.stem}.cover-{i}.go")), + cover_var=f"GoCover_{import_path_hash}_{i}", + ) + ) + + cover_results = await MultiGet( + Get( + ApplyCodeCoverageToFileResult, + ApplyCodeCoverageToFileRequest( + digest=go_file_digest, + go_file=os.path.join(request.dir_path, m.go_file), + cover_go_file=os.path.join(request.dir_path, m.cover_go_file), + mode=request.cover_mode, + cover_var=m.cover_var, + ), + ) + for m, go_file_digest in zip(file_metadatas, subsetted_digests) + ) + + digest = await Get(Digest, MergeDigests([r.digest for r in cover_results])) + return ApplyCodeCoverageResult( + digest=digest, + cover_file_metadatas=tuple(file_metadatas), + ) + + +@dataclass(frozen=True) +class GenerateCoverageSetupCodeRequest: + packages: FrozenOrderedSet[BuiltGoPackageCodeCoverageMetadata] + cover_mode: GoCoverMode + + +@dataclass(frozen=True) +class GenerateCoverageSetupCodeResult: + PATH = "pants_cover_setup.go" + digest: Digest + + +COVERAGE_SETUP_CODE = """\ +package main + +import ( + "testing" + +@IMPORTS@ +) + +var ( + coverCounters = make(map[string][]uint32) + coverBlocks = make(map[string][]testing.CoverBlock) +) + +func coverRegisterFile(fileName string, counter []uint32, pos []uint32, numStmts []uint16) { + if 3*len(counter) != len(pos) || len(counter) != len(numStmts) { + panic("coverage: mismatched sizes") + } + if coverCounters[fileName] != nil { + // Already registered. + return + } + coverCounters[fileName] = counter + block := make([]testing.CoverBlock, len(counter)) + for i := range counter { + block[i] = testing.CoverBlock{ + Line0: pos[3*i+0], + Col0: uint16(pos[3*i+2]), + Line1: pos[3*i+1], + Col1: uint16(pos[3*i+2]>>16), + Stmts: numStmts[i], + } + } + coverBlocks[fileName] = block +} + +func init() { +@REGISTRATIONS@ +} + +func registerCover() { + testing.RegisterCover(testing.Cover{ + Mode: "@COVER_MODE@", + Counters: coverCounters, + Blocks: coverBlocks, + CoveredPackages: "", + }) +} +""" + + +@rule +async def generate_go_coverage_setup_code( + request: GenerateCoverageSetupCodeRequest, +) -> GenerateCoverageSetupCodeResult: + imports_partial = "".join( + [f' _cover{i} "{pkg.import_path}"\n' for i, pkg in enumerate(request.packages)] + ).rstrip() + + registrations_partial = "".join( + [ + f' coverRegisterFile("{m.file_id}", _cover{i}.{m.cover_var}.Count[:], ' + f"_cover{i}.{m.cover_var}.Pos[:], _cover{i}.{m.cover_var}.NumStmt[:])\n" + for i, pkg in enumerate(request.packages) + for m in pkg.cover_file_metadatas + ] + ).rstrip() + + content = ( + COVERAGE_SETUP_CODE.replace("@IMPORTS@", imports_partial) + .replace("@REGISTRATIONS@", registrations_partial) + .replace("@COVER_MODE@", request.cover_mode.value) + ) + + digest = await Get( + Digest, CreateDigest([FileContent(GenerateCoverageSetupCodeResult.PATH, content.encode())]) + ) + return GenerateCoverageSetupCodeResult(digest=digest) + + +def rules(): + return collect_rules() diff --git a/src/python/pants/backend/go/util_rules/coverage_output.py b/src/python/pants/backend/go/util_rules/coverage_output.py new file mode 100644 index 000000000000..83d82701638f --- /dev/null +++ b/src/python/pants/backend/go/util_rules/coverage_output.py @@ -0,0 +1,81 @@ +# Copyright 2022 Pants project contributors (see CONTRIBUTORS.md). +# Licensed under the Apache License, Version 2.0 (see LICENSE). +from __future__ import annotations + +from dataclasses import dataclass + +from pants.backend.go.subsystems.gotest import GoTestSubsystem +from pants.backend.go.util_rules.coverage import GoCoverageData +from pants.core.goals.test import CoverageDataCollection, CoverageReports, FilesystemCoverageReport +from pants.core.util_rules import distdir +from pants.core.util_rules.distdir import DistDir +from pants.engine.engine_aware import EngineAwareParameter +from pants.engine.internals.native_engine import Digest, Snapshot +from pants.engine.internals.selectors import Get, MultiGet +from pants.engine.rules import collect_rules, rule +from pants.engine.unions import UnionRule +from pants.util.logging import LogLevel + + +class GoCoverageDataCollection(CoverageDataCollection): + element_type = GoCoverageData + + +@dataclass(frozen=True) +class RenderGoCoverageReportRequest(EngineAwareParameter): + raw_report: GoCoverageData + + def debug_hint(self) -> str | None: + return self.raw_report.import_path + + +@dataclass(frozen=True) +class RenderGoCoverageReportResult: + coverage_report: FilesystemCoverageReport + + +@rule +async def go_render_coverage_report( + request: RenderGoCoverageReportRequest, + distdir_value: DistDir, + go_test_subsystem: GoTestSubsystem, +) -> RenderGoCoverageReportResult: + output_dir = go_test_subsystem.coverage_output_dir( + distdir=distdir_value, import_path=request.raw_report.import_path + ) + snapshot = await Get(Snapshot, Digest, request.raw_report.coverage_digest) + coverage_report = FilesystemCoverageReport( + coverage_insufficient=False, + result_snapshot=snapshot, + directory_to_materialize_to=output_dir, + report_file=output_dir / "cover.out", + report_type="go_cover", + ) + return RenderGoCoverageReportResult( + coverage_report=coverage_report, + ) + + +@rule(desc="Merge Go coverage data", level=LogLevel.DEBUG) +async def go_gather_coverage_reports( + raw_coverage_reports: GoCoverageDataCollection, +) -> CoverageReports: + coverage_report_results = await MultiGet( + Get( + RenderGoCoverageReportResult, + RenderGoCoverageReportRequest( + raw_report=raw_coverage_report, + ), + ) + for raw_coverage_report in raw_coverage_reports + ) + + return CoverageReports(reports=tuple(r.coverage_report for r in coverage_report_results)) + + +def rules(): + return ( + *collect_rules(), + *distdir.rules(), + UnionRule(CoverageDataCollection, GoCoverageDataCollection), + ) diff --git a/src/python/pants/backend/go/util_rules/coverage_test.py b/src/python/pants/backend/go/util_rules/coverage_test.py new file mode 100644 index 000000000000..4bda6d722cba --- /dev/null +++ b/src/python/pants/backend/go/util_rules/coverage_test.py @@ -0,0 +1,118 @@ +# Copyright 2022 Pants project contributors (see CONTRIBUTORS.md). +# Licensed under the Apache License, Version 2.0 (see LICENSE). +from __future__ import annotations + +import textwrap + +import pytest + +from pants.backend.go import target_type_rules +from pants.backend.go.goals.test import GoTestFieldSet +from pants.backend.go.goals.test import rules as test_rules +from pants.backend.go.target_types import GoModTarget, GoPackageTarget +from pants.backend.go.util_rules import ( + assembly, + build_pkg, + build_pkg_target, + coverage, + coverage_output, + first_party_pkg, + go_mod, + link, + sdk, + tests_analysis, + third_party_pkg, +) +from pants.backend.go.util_rules.coverage import GoCoverageData +from pants.backend.go.util_rules.coverage_output import GoCoverageDataCollection +from pants.build_graph.address import Address +from pants.core.goals.test import ( + CoverageReports, + FilesystemCoverageReport, + TestResult, + get_filtered_environment, +) +from pants.core.goals.test import rules as core_test_rules +from pants.core.target_types import FileTarget +from pants.core.util_rules import source_files +from pants.engine.fs import DigestContents +from pants.engine.internals.native_engine import Digest +from pants.engine.rules import QueryRule +from pants.testutil.rule_runner import RuleRunner + + +@pytest.fixture +def rule_runner() -> RuleRunner: + rule_runner = RuleRunner( + rules=[ + *test_rules(), + *assembly.rules(), + *build_pkg.rules(), + *build_pkg_target.rules(), + *coverage.rules(), + *coverage_output.rules(), + *first_party_pkg.rules(), + *go_mod.rules(), + *link.rules(), + *sdk.rules(), + *target_type_rules.rules(), + *tests_analysis.rules(), + *third_party_pkg.rules(), + *source_files.rules(), + get_filtered_environment, + *core_test_rules(), + QueryRule(TestResult, (GoTestFieldSet,)), + QueryRule(CoverageReports, (GoCoverageDataCollection,)), + QueryRule(DigestContents, (Digest,)), + ], + target_types=[GoModTarget, GoPackageTarget, FileTarget], + ) + rule_runner.set_options( + ["--go-test-args=-v -bench=.", "--test-use-coverage"], env_inherit={"PATH"} + ) + return rule_runner + + +def test_basic_coverage(rule_runner: RuleRunner) -> None: + rule_runner.write_files( + { + "foo/BUILD": "go_mod(name='mod')\ngo_package()", + "foo/go.mod": "module foo", + "foo/add.go": textwrap.dedent( + """ + package foo + func add(x, y int) int { + return x + y + } + """ + ), + "foo/add_test.go": textwrap.dedent( + """ + package foo + import "testing" + func TestAdd(t *testing.T) { + if add(2, 3) != 5 { + t.Fail() + } + } + """ + ), + } + ) + tgt = rule_runner.get_target(Address("foo")) + result = rule_runner.request(TestResult, [GoTestFieldSet.create(tgt)]) + assert result.exit_code == 0 + assert "PASS: TestAdd" in result.stdout + coverage_data = result.coverage_data + assert coverage_data is not None + assert isinstance(coverage_data, GoCoverageData) + assert coverage_data.import_path == "foo" + coverage_reports = rule_runner.request( + CoverageReports, [GoCoverageDataCollection([coverage_data])] + ) + assert len(coverage_reports.reports) == 1 + coverage_report = coverage_reports.reports[0] + assert isinstance(coverage_report, FilesystemCoverageReport) + digest_contents = rule_runner.request(DigestContents, (coverage_report.result_snapshot.digest,)) + assert len(digest_contents) == 1 + assert digest_contents[0].path == "cover.out" diff --git a/src/python/pants/backend/go/util_rules/tests_analysis.py b/src/python/pants/backend/go/util_rules/tests_analysis.py index 9f401433e188..f845754d25b6 100644 --- a/src/python/pants/backend/go/util_rules/tests_analysis.py +++ b/src/python/pants/backend/go/util_rules/tests_analysis.py @@ -23,6 +23,7 @@ class GenerateTestMainRequest(EngineAwareParameter): test_paths: FrozenOrderedSet[str] xtest_paths: FrozenOrderedSet[str] import_path: str + register_cover: bool address: Address def debug_hint(self) -> str: @@ -51,16 +52,22 @@ async def generate_testmain(request: GenerateTestMainRequest) -> GeneratedTestMa test_paths = tuple(f"{GeneratedTestMain.TEST_PKG}:{path}" for path in request.test_paths) xtest_paths = tuple(f"{GeneratedTestMain.XTEST_PKG}:{path}" for path in request.xtest_paths) + env = {} + if request.register_cover: + env["GENERATE_COVER"] = "1" + result = await Get( FallibleProcessResult, Process( argv=("./analyzer", request.import_path, *test_paths, *xtest_paths), input_digest=input_digest, + env=env, description=f"Analyze Go test sources for {request.address}", level=LogLevel.DEBUG, output_files=("_testmain.go",), ), ) + if result.exit_code != 0: return GeneratedTestMain( digest=EMPTY_DIGEST, diff --git a/src/python/pants/backend/go/util_rules/tests_analysis_test.py b/src/python/pants/backend/go/util_rules/tests_analysis_test.py index e50c62437004..c3c8deb1b793 100644 --- a/src/python/pants/backend/go/util_rules/tests_analysis_test.py +++ b/src/python/pants/backend/go/util_rules/tests_analysis_test.py @@ -77,11 +77,12 @@ def test_basic_test_analysis(rule_runner: RuleRunner) -> None: GeneratedTestMain, [ GenerateTestMainRequest( - input_digest, - FrozenOrderedSet(["foo_test.go"]), - FrozenOrderedSet(["bar_test.go"]), - "foo", - Address("foo"), + digest=input_digest, + test_paths=FrozenOrderedSet(["foo_test.go"]), + xtest_paths=FrozenOrderedSet(["bar_test.go"]), + import_path="foo", + register_cover=False, + address=Address("foo"), ) ], ) @@ -122,11 +123,12 @@ def test_collect_examples(rule_runner: RuleRunner) -> None: GeneratedTestMain, [ GenerateTestMainRequest( - input_digest, - FrozenOrderedSet(["foo_test.go"]), - FrozenOrderedSet(), - "foo", - Address("foo"), + digest=input_digest, + test_paths=FrozenOrderedSet(["foo_test.go"]), + xtest_paths=FrozenOrderedSet(), + import_path="foo", + register_cover=False, + address=Address("foo"), ) ], ) @@ -167,11 +169,12 @@ def test_incorrect_signatures(rule_runner: RuleRunner) -> None: GeneratedTestMain, [ GenerateTestMainRequest( - input_digest, - FrozenOrderedSet(["foo_test.go"]), - FrozenOrderedSet(), - "foo", - Address("foo"), + digest=input_digest, + test_paths=FrozenOrderedSet(["foo_test.go"]), + xtest_paths=FrozenOrderedSet(), + import_path="foo", + register_cover=False, + address=Address("foo"), ) ], ) @@ -202,11 +205,12 @@ def test_duplicate_test_mains_same_file(rule_runner: RuleRunner) -> None: GeneratedTestMain, [ GenerateTestMainRequest( - input_digest, - FrozenOrderedSet(["foo_test.go", "bar_test.go"]), - FrozenOrderedSet(), - "foo", - Address("foo"), + digest=input_digest, + test_paths=FrozenOrderedSet(["foo_test.go", "bar_test.go"]), + xtest_paths=FrozenOrderedSet(), + import_path="foo", + register_cover=False, + address=Address("foo"), ) ], ) @@ -242,11 +246,12 @@ def test_duplicate_test_mains_different_files(rule_runner: RuleRunner) -> None: GeneratedTestMain, [ GenerateTestMainRequest( - input_digest, - FrozenOrderedSet(["foo_test.go", "bar_test.go"]), - FrozenOrderedSet(), - "foo", - Address("foo"), + digest=input_digest, + test_paths=FrozenOrderedSet(["foo_test.go", "bar_test.go"]), + xtest_paths=FrozenOrderedSet(), + import_path="foo", + register_cover=False, + address=Address("foo"), ) ], )