internal/testrunner/coverage.go (199 lines of code) (raw):
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
// or more contributor license agreements. Licensed under the Elastic License;
// you may not use this file except in compliance with the Elastic License.
package testrunner
import (
"bufio"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"time"
"github.com/elastic/elastic-package/internal/files"
)
// GenerateBasePackageCoverageReport generates a coverage report where all files under the root path are
// marked as not covered. It ignores files under _dev directories.
func GenerateBasePackageCoverageReport(pkgName, rootPath, format string) (CoverageReport, error) {
repoPath, err := files.FindRepositoryRootDirectory()
if err != nil {
return nil, fmt.Errorf("failed to find repository root directory: %w", err)
}
var coverage CoverageReport
err = filepath.WalkDir(rootPath, func(match string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if d.Name() == "_dev" {
return fs.SkipDir
}
return nil
}
// Exclude changelog from coverage reports, as changelogs are frequently modified and not
// relevant to tests.
if d.Name() == "changelog.yml" && filepath.Dir(match) == filepath.Clean(rootPath) {
return nil
}
fileCoverage, err := generateBaseFileCoverageReport(repoPath, pkgName, match, format, false)
if err != nil {
return fmt.Errorf("failed to generate base coverage for \"%s\": %w", match, err)
}
if coverage == nil {
coverage = fileCoverage
return nil
}
err = coverage.Merge(fileCoverage)
if err != nil {
return fmt.Errorf("cannot merge coverages: %w", err)
}
return nil
})
// If the directory is not found, give it as valid, will return an empty coverage. This is also useful for mocked tests.
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("failed to walk package directory %s: %w", rootPath, err)
}
return coverage, nil
}
// GenerateBaseFileCoverageReport generates a coverage report for a given file, where all the file is marked as covered or uncovered.
func GenerateBaseFileCoverageReport(pkgName, path, format string, covered bool) (CoverageReport, error) {
repoPath, err := files.FindRepositoryRootDirectory()
if err != nil {
return nil, fmt.Errorf("failed to find repository root directory: %w", err)
}
return generateBaseFileCoverageReport(repoPath, pkgName, path, format, covered)
}
// GenerateBaseFileCoverageReport generates a coverage report for all the files matching any of the given patterns. The complete
// files are marked as fully covered or uncovered depending on the given value.
func GenerateBaseFileCoverageReportGlob(pkgName string, patterns []string, format string, covered bool) (CoverageReport, error) {
repoPath, err := files.FindRepositoryRootDirectory()
if err != nil {
return nil, fmt.Errorf("failed to find repository root directory: %w", err)
}
var coverage CoverageReport
for _, pattern := range patterns {
matches, err := filepath.Glob(pattern)
if err != nil {
return nil, err
}
for _, match := range matches {
fileCoverage, err := generateBaseFileCoverageReport(repoPath, pkgName, match, format, covered)
if err != nil {
return nil, fmt.Errorf("failed to generate base coverage for \"%s\": %w", match, err)
}
if coverage == nil {
coverage = fileCoverage
continue
}
err = coverage.Merge(fileCoverage)
if err != nil {
return nil, fmt.Errorf("cannot merge coverages: %w", err)
}
}
}
return coverage, nil
}
func generateBaseFileCoverageReport(repoPath, pkgName, path, format string, covered bool) (CoverageReport, error) {
switch format {
case "cobertura":
return generateBaseCoberturaFileCoverageReport(repoPath, pkgName, path, covered)
case "generic":
return generateBaseGenericFileCoverageReport(repoPath, pkgName, path, covered)
default:
return nil, fmt.Errorf("unknwon coverage format %s", format)
}
}
func generateBaseCoberturaFileCoverageReport(repoPath, pkgName, path string, covered bool) (*CoberturaCoverage, error) {
coveragePath, err := filepath.Rel(repoPath, path)
if err != nil {
return nil, fmt.Errorf("failed to obtain path inside repository for %s", path)
}
ext := filepath.Ext(path)
class := CoberturaClass{
Name: pkgName + "." + strings.TrimSuffix(filepath.Base(path), ext),
Filename: coveragePath,
}
pkg := CoberturaPackage{
Name: pkgName,
Classes: []*CoberturaClass{
&class,
},
}
coverage := CoberturaCoverage{
Sources: []*CoberturaSource{
{
Path: path,
},
},
Packages: []*CoberturaPackage{
&pkg,
},
Timestamp: time.Now().UnixNano(),
}
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("failed to open file: %v", err)
}
defer f.Close()
hits := int64(0)
if covered {
hits = 1
}
lines, err := countReaderLines(f)
if err != nil {
return nil, fmt.Errorf("failed to count lines in file: %w", err)
}
for i := range lines {
line := CoberturaLine{
Number: i + 1,
Hits: hits,
}
class.Lines = append(class.Lines, &line)
}
coverage.LinesValid = int64(lines)
coverage.LinesCovered = int64(lines) * hits
return &coverage, nil
}
func generateBaseGenericFileCoverageReport(repoPath, _, path string, covered bool) (*GenericCoverage, error) {
coveragePath, err := filepath.Rel(repoPath, path)
if err != nil {
return nil, fmt.Errorf("failed to obtain path inside repository for %s", path)
}
file := GenericFile{
Path: coveragePath,
}
coverage := GenericCoverage{
Version: 1,
Timestamp: time.Now().UnixNano(),
TestType: fmt.Sprintf("Coverage for %s", coveragePath),
Files: []*GenericFile{
&file,
},
}
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("failed to open file: %v", err)
}
defer f.Close()
lines, err := countReaderLines(f)
if err != nil {
return nil, fmt.Errorf("failed to count lines in file: %w", err)
}
for i := range lines {
line := GenericLine{
LineNumber: int64(i) + 1,
Covered: covered,
}
file.Lines = append(file.Lines, &line)
}
return &coverage, nil
}
func countReaderLines(r io.Reader) (int, error) {
count := 0
buffered := bufio.NewReader(r)
for {
c, _, err := buffered.ReadRune()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return 0, fmt.Errorf("failed to read rune: %w", err)
}
if c != '\n' {
continue
}
count += 1
}
return count, nil
}