188 lines
4.8 KiB
Go
188 lines
4.8 KiB
Go
package coverage
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
type coverageBlock struct {
|
|
file string
|
|
stmts float64
|
|
hitCount float64
|
|
}
|
|
|
|
type fileCoverage struct {
|
|
covered float64
|
|
total float64
|
|
}
|
|
|
|
func repoRoot(tb testing.TB) string {
|
|
tb.Helper()
|
|
_, thisFile, _, ok := runtime.Caller(0)
|
|
if !ok {
|
|
tb.Fatalf("unable to resolve caller path")
|
|
}
|
|
return filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", ".."))
|
|
}
|
|
|
|
// runCoverageCommand runs one orchestration or CLI step.
|
|
// Signature: runCoverageCommand(t *testing.T, dir string, coverFile string, args ...string).
|
|
// Why: coverage is now merged from both root and top-level testing modules, so
|
|
// command execution needs one shared helper to avoid drift between invocations.
|
|
func runCoverageCommand(t *testing.T, dir string, coverFile string, args ...string) {
|
|
t.Helper()
|
|
cmdArgs := append([]string{"test"}, args...)
|
|
cmdArgs = append(cmdArgs, "-coverprofile="+coverFile)
|
|
cmd := exec.Command("go", cmdArgs...)
|
|
cmd.Dir = dir
|
|
cmd.Env = os.Environ()
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("go test coverage run failed in %s: %v\n%s", dir, err, out)
|
|
}
|
|
}
|
|
|
|
// parseCoverageProfile runs one orchestration or CLI step.
|
|
// Signature: parseCoverageProfile(t *testing.T, path string, blocks map[string]coverageBlock).
|
|
// Why: merged coverage requires block-level union logic so hits from either
|
|
// module count toward per-file coverage without double-counting statements.
|
|
func parseCoverageProfile(t *testing.T, path string, blocks map[string]coverageBlock) {
|
|
t.Helper()
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
t.Fatalf("open coverprofile %s: %v", path, err)
|
|
}
|
|
defer f.Close()
|
|
|
|
s := bufio.NewScanner(f)
|
|
for s.Scan() {
|
|
line := strings.TrimSpace(s.Text())
|
|
if line == "" || strings.HasPrefix(line, "mode:") {
|
|
continue
|
|
}
|
|
parts := strings.Fields(line)
|
|
if len(parts) != 3 {
|
|
t.Fatalf("unexpected coverprofile line: %q", line)
|
|
}
|
|
loc := parts[0]
|
|
stmts, err := strconv.ParseFloat(parts[1], 64)
|
|
if err != nil {
|
|
t.Fatalf("parse statement count %q: %v", parts[1], err)
|
|
}
|
|
hitCount, err := strconv.ParseFloat(parts[2], 64)
|
|
if err != nil {
|
|
t.Fatalf("parse hit count %q: %v", parts[2], err)
|
|
}
|
|
file := strings.SplitN(loc, ":", 2)[0]
|
|
key := loc + "|" + parts[1]
|
|
block := blocks[key]
|
|
block.file = file
|
|
block.stmts = stmts
|
|
if hitCount > block.hitCount {
|
|
block.hitCount = hitCount
|
|
}
|
|
blocks[key] = block
|
|
}
|
|
if err := s.Err(); err != nil {
|
|
t.Fatalf("scan coverprofile %s: %v", path, err)
|
|
}
|
|
}
|
|
|
|
func TestPerFileCoverageReport(t *testing.T) {
|
|
root := repoRoot(t)
|
|
tmp := t.TempDir()
|
|
rootCover := filepath.Join(tmp, "ananke.root.cover.out")
|
|
configCover := filepath.Join(tmp, "ananke.testing.config.cover.out")
|
|
testingCover := filepath.Join(tmp, "ananke.testing.cover.out")
|
|
|
|
runCoverageCommand(t, root, rootCover, "./...")
|
|
runCoverageCommand(
|
|
t,
|
|
filepath.Join(root, "testing"),
|
|
configCover,
|
|
"./config",
|
|
"-coverpkg=scm.bstein.dev/bstein/ananke/...",
|
|
)
|
|
|
|
runCoverageCommand(
|
|
t,
|
|
filepath.Join(root, "testing"),
|
|
testingCover,
|
|
"./hygiene",
|
|
"./orchestrator",
|
|
"./state",
|
|
"./sshutil",
|
|
"./ups",
|
|
"-coverpkg=scm.bstein.dev/bstein/ananke/...",
|
|
)
|
|
|
|
blocks := map[string]coverageBlock{}
|
|
parseCoverageProfile(t, rootCover, blocks)
|
|
parseCoverageProfile(t, configCover, blocks)
|
|
parseCoverageProfile(t, testingCover, blocks)
|
|
|
|
byFile := map[string]*fileCoverage{}
|
|
for _, block := range blocks {
|
|
fc := byFile[block.file]
|
|
if fc == nil {
|
|
fc = &fileCoverage{}
|
|
byFile[block.file] = fc
|
|
}
|
|
fc.total += block.stmts
|
|
if block.hitCount > 0 {
|
|
fc.covered += block.stmts
|
|
}
|
|
}
|
|
|
|
target := 95.0
|
|
if raw := strings.TrimSpace(os.Getenv("ANANKE_PER_FILE_COVERAGE_TARGET")); raw != "" {
|
|
parsed, err := strconv.ParseFloat(raw, 64)
|
|
if err != nil {
|
|
t.Fatalf("invalid ANANKE_PER_FILE_COVERAGE_TARGET=%q: %v", raw, err)
|
|
}
|
|
target = parsed
|
|
}
|
|
enforce := strings.TrimSpace(os.Getenv("ANANKE_ENFORCE_COVERAGE")) == "1"
|
|
|
|
keys := make([]string, 0, len(byFile))
|
|
for file := range byFile {
|
|
if strings.Contains(file, "/testing/") {
|
|
continue
|
|
}
|
|
keys = append(keys, file)
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
var report bytes.Buffer
|
|
report.WriteString("per-file coverage\n")
|
|
under := make([]string, 0)
|
|
for _, file := range keys {
|
|
fc := byFile[file]
|
|
pct := 100.0
|
|
if fc.total > 0 {
|
|
pct = (fc.covered / fc.total) * 100.0
|
|
}
|
|
report.WriteString(fmt.Sprintf("- %s: %.1f%%\n", file, pct))
|
|
if pct < target {
|
|
under = append(under, fmt.Sprintf("%s (%.1f%% < %.1f%%)", file, pct, target))
|
|
}
|
|
}
|
|
t.Log(report.String())
|
|
|
|
if len(under) > 0 && enforce {
|
|
t.Fatalf("files below coverage target:\n%s", strings.Join(under, "\n"))
|
|
}
|
|
if len(under) > 0 && !enforce {
|
|
t.Logf("coverage target %.1f%% is not enforced yet; %d files are below target", target, len(under))
|
|
}
|
|
}
|