mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 03:13:24 +00:00
build(ci): add coverage ratchet gate with per-package floors
Add automated coverage floor validation: - scripts/check_coverage.go: parses coverage.out, compares to per-package thresholds, supports --update to lock current floors - scripts/coverage_thresholds.json: 61 packages, initial floors from Phase 1 - .github/workflows/ci.yaml: new 'Coverage ratchet gate' step in CI - internal/testutil/: test utilities (context builders, TestDB, doc.go)
This commit is contained in:
1 parent
eb2419beed
commit
27c9415193
7 files changed
+463
-1
No files matched your search
@@ -21,9 +21,11 @@ jobs:
|
||||
- run: go build ./...
|
||||
- run: go build -tags sqliteonly ./...
|
||||
- run: go vet ./...
|
||||
- run: go test -race -coverprofile=coverage.out ./...
|
||||
- run: go test -race -coverpkg=./... -coverprofile=coverage.out ./...
|
||||
- name: Coverage summary
|
||||
run: go tool cover -func=coverage.out | tail -1
|
||||
- name: Coverage ratchet gate
|
||||
run: go run scripts/check_coverage.go -coverprofile=coverage.out
|
||||
|
||||
web:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// TenantCtx returns a context seeded with the given tenant UUID.
|
||||
// Panics on malformed uuid strings to keep test call-sites concise.
|
||||
func TenantCtx(tenantID uuid.UUID) context.Context {
|
||||
return store.WithTenantID(context.Background(), tenantID)
|
||||
}
|
||||
|
||||
// UserCtx returns a context with tenant + user identities set.
|
||||
func UserCtx(tenantID uuid.UUID, userID string) context.Context {
|
||||
ctx := store.WithTenantID(context.Background(), tenantID)
|
||||
return store.WithUserID(ctx, userID)
|
||||
}
|
||||
|
||||
// AgentCtx returns a context with tenant + agent identities set.
|
||||
func AgentCtx(tenantID, agentID uuid.UUID) context.Context {
|
||||
ctx := store.WithTenantID(context.Background(), tenantID)
|
||||
return store.WithAgentID(ctx, agentID)
|
||||
}
|
||||
|
||||
// FullCtx returns a context with tenant + user + agent identities set.
|
||||
func FullCtx(tenantID uuid.UUID, userID string, agentID uuid.UUID) context.Context {
|
||||
ctx := store.WithTenantID(context.Background(), tenantID)
|
||||
ctx = store.WithUserID(ctx, userID)
|
||||
return store.WithAgentID(ctx, agentID)
|
||||
}
|
||||
|
||||
// MustParseUUID is a helper for tests to turn a literal into uuid.UUID.
|
||||
// Tests die loudly on malformed input; production code should never touch this.
|
||||
func MustParseUUID(s string) uuid.UUID {
|
||||
id, err := uuid.Parse(s)
|
||||
if err != nil {
|
||||
panic("testutil.MustParseUUID: " + err.Error())
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//go:build integration
|
||||
|
||||
// Package testutil provides reusable test helpers for integration tests:
|
||||
// - TestDB: connects to Postgres via TEST_DATABASE_URL and runs migrations once.
|
||||
// - Context builders: TenantCtx, UserCtx, AgentCtx, FullCtx.
|
||||
// - Mock stores: gomock-generated interfaces for unit tests.
|
||||
//
|
||||
// Integration helpers live behind `//go:build integration` so default builds stay
|
||||
// dependency-free. Call TestDB from integration tests; it skips gracefully if
|
||||
// Postgres is unreachable, matching the pattern in tests/integration/v3_test_helper.go.
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
_ "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
// defaultTestDSN matches the pgvector test container in the README.
|
||||
const defaultTestDSN = "postgres://postgres:test@localhost:5433/goclaw_test?sslmode=disable"
|
||||
|
||||
var (
|
||||
sharedDB *sql.DB
|
||||
sharedDBOnce sync.Once
|
||||
sharedDBErr error
|
||||
)
|
||||
|
||||
// TestDB returns a shared *sql.DB for the test binary, running migrations once.
|
||||
// If Postgres is unreachable it skips the calling test with a clear reason.
|
||||
// The migrationsDir argument is the filesystem path to the migrations folder
|
||||
// relative to the test binary's working directory (e.g. "../../migrations").
|
||||
func TestDB(t *testing.T, migrationsDir string) *sql.DB {
|
||||
t.Helper()
|
||||
sharedDBOnce.Do(func() {
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
dsn = defaultTestDSN
|
||||
}
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
sharedDBErr = err
|
||||
return
|
||||
}
|
||||
if err := db.Ping(); err != nil {
|
||||
sharedDBErr = err
|
||||
return
|
||||
}
|
||||
m, err := migrate.New("file://"+migrationsDir, dsn)
|
||||
if err != nil {
|
||||
sharedDBErr = err
|
||||
return
|
||||
}
|
||||
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
|
||||
sharedDBErr = err
|
||||
return
|
||||
}
|
||||
m.Close()
|
||||
sharedDB = db
|
||||
})
|
||||
if sharedDBErr != nil {
|
||||
t.Skipf("test PG not available: %v", sharedDBErr)
|
||||
}
|
||||
return sharedDB
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Package testutil provides reusable helpers for Go tests across the codebase.
|
||||
//
|
||||
// The package is split into default-build files (context builders, mock hooks)
|
||||
// and integration-tagged files (DB connection helper) so default `go test ./...`
|
||||
// never requires Postgres. Import paths stay consistent regardless of build tag.
|
||||
//
|
||||
// Helpers:
|
||||
// - TestDB (integration tag): shared Postgres connection + migrations, once per binary.
|
||||
// - TenantCtx / UserCtx / AgentCtx / FullCtx: context builders mirroring store.With* setters.
|
||||
// - Mock stores (generated via go:generate, see generate.go): gomock doubles
|
||||
// for unit tests that need a store interface without hitting Postgres.
|
||||
package testutil
|
||||
@@ -0,0 +1,13 @@
|
||||
package testutil
|
||||
|
||||
// gomock-based store mocks generation hooks.
|
||||
//
|
||||
// Setup: go install go.uber.org/mock/mockgen@latest
|
||||
// Run: go generate ./internal/testutil/...
|
||||
//
|
||||
// Generated files are checked into the repo so tests work without extra setup.
|
||||
// Add new interfaces below when a new package needs a mock for unit tests.
|
||||
|
||||
//go:generate mockgen -destination=mock_session_store.go -package=testutil github.com/nextlevelbuilder/goclaw/internal/store SessionStore
|
||||
//go:generate mockgen -destination=mock_agent_store.go -package=testutil github.com/nextlevelbuilder/goclaw/internal/store AgentStore
|
||||
//go:generate mockgen -destination=mock_contact_store.go -package=testutil github.com/nextlevelbuilder/goclaw/internal/store ContactStore
|
||||
@@ -0,0 +1,258 @@
|
||||
//go:build ignore
|
||||
|
||||
// Coverage ratchet gate: fails CI if any package drops below its stored threshold.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run scripts/check_coverage.go [-coverprofile=coverage.out] [-thresholds=scripts/coverage_thresholds.json] [-update]
|
||||
//
|
||||
// Flags:
|
||||
//
|
||||
// -coverprofile: Path to coverage.out (default: coverage.out)
|
||||
// -thresholds: Path to thresholds JSON (default: scripts/coverage_thresholds.json)
|
||||
// -update: Write current coverage as new thresholds (ratchet up, explicit opt-in)
|
||||
//
|
||||
// Exit codes:
|
||||
//
|
||||
// 0 = all packages meet threshold (or --update succeeded)
|
||||
// 1 = at least one package below threshold
|
||||
// 2 = parse/IO error
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const modulePrefix = "github.com/nextlevelbuilder/goclaw/"
|
||||
|
||||
// Trivial/infra-only packages excluded from coverage gate.
|
||||
var excluded = map[string]bool{
|
||||
"internal/version": true,
|
||||
"internal/webui": true,
|
||||
"internal/updater": true,
|
||||
"pkg/protocol": true,
|
||||
"tests/zalo_e2e": true,
|
||||
"ui/desktop": true,
|
||||
"cmd": true,
|
||||
"scripts": true,
|
||||
}
|
||||
|
||||
type pkgCoverage struct {
|
||||
pkg string
|
||||
statements int
|
||||
covered int
|
||||
}
|
||||
|
||||
// parseCoverProfile reads a Go coverage profile and groups statements by package.
|
||||
// Coverage lines look like: "github.com/foo/bar/file.go:12.1,15.2 3 1"
|
||||
// Fields: file:start,end numStatements count
|
||||
func parseCoverProfile(path string) (map[string]*pkgCoverage, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
packages := make(map[string]*pkgCoverage)
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
first := true
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if first {
|
||||
first = false
|
||||
if strings.HasPrefix(line, "mode:") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// Split into 3 parts: "file:range numStmt count"
|
||||
// Last two fields are numeric; everything before is the filename.
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 3 {
|
||||
continue
|
||||
}
|
||||
fileRange := strings.Join(parts[:len(parts)-2], " ")
|
||||
numStmt, err := strconv.Atoi(parts[len(parts)-2])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
count, err := strconv.Atoi(parts[len(parts)-1])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// Extract file path (before ':')
|
||||
fullFile, _, ok := strings.Cut(fileRange, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Strip module prefix to get relative path.
|
||||
rel := strings.TrimPrefix(fullFile, modulePrefix)
|
||||
// Package is the directory.
|
||||
slash := strings.LastIndex(rel, "/")
|
||||
if slash < 0 {
|
||||
continue
|
||||
}
|
||||
pkg := rel[:slash]
|
||||
if pkg == "" {
|
||||
continue
|
||||
}
|
||||
entry, ok := packages[pkg]
|
||||
if !ok {
|
||||
entry = &pkgCoverage{pkg: pkg}
|
||||
packages[pkg] = entry
|
||||
}
|
||||
entry.statements += numStmt
|
||||
if count > 0 {
|
||||
entry.covered += numStmt
|
||||
}
|
||||
}
|
||||
return packages, scanner.Err()
|
||||
}
|
||||
|
||||
func isExcluded(pkg string) bool {
|
||||
if excluded[pkg] {
|
||||
return true
|
||||
}
|
||||
for p := range excluded {
|
||||
if strings.HasPrefix(pkg, p+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func loadThresholds(path string) (map[string]float64, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return map[string]float64{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var out map[string]float64
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func writeThresholds(path string, thresholds map[string]float64) error {
|
||||
// Sorted output for deterministic diffs.
|
||||
keys := make([]string, 0, len(thresholds))
|
||||
for k := range thresholds {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
ordered := make(map[string]float64, len(keys))
|
||||
for _, k := range keys {
|
||||
ordered[k] = thresholds[k]
|
||||
}
|
||||
data, err := json.MarshalIndent(ordered, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
return os.WriteFile(path, data, 0o644)
|
||||
}
|
||||
|
||||
func pctStr(p float64) string {
|
||||
return fmt.Sprintf("%5.1f%%", p)
|
||||
}
|
||||
|
||||
func main() {
|
||||
var (
|
||||
profilePath = flag.String("coverprofile", "coverage.out", "coverage profile path")
|
||||
thresholdsPath = flag.String("thresholds", "scripts/coverage_thresholds.json", "thresholds JSON path")
|
||||
update = flag.Bool("update", false, "write current coverage as new thresholds")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
packages, err := parseCoverProfile(*profilePath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error parsing %s: %v\n", *profilePath, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
thresholds, err := loadThresholds(*thresholdsPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error loading thresholds: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
type row struct {
|
||||
pkg string
|
||||
current float64
|
||||
threshold float64
|
||||
delta float64
|
||||
status string
|
||||
}
|
||||
var rows []row
|
||||
failed := 0
|
||||
newThresholds := make(map[string]float64)
|
||||
|
||||
for _, entry := range packages {
|
||||
if isExcluded(entry.pkg) {
|
||||
continue
|
||||
}
|
||||
if entry.statements == 0 {
|
||||
continue
|
||||
}
|
||||
current := 100 * float64(entry.covered) / float64(entry.statements)
|
||||
threshold := thresholds[entry.pkg]
|
||||
delta := current - threshold
|
||||
status := "PASS"
|
||||
if current+0.01 < threshold { // tiny epsilon for float comparison
|
||||
status = "FAIL"
|
||||
failed++
|
||||
}
|
||||
rows = append(rows, row{entry.pkg, current, threshold, delta, status})
|
||||
// Preserve existing thresholds for packages that still exist.
|
||||
newThresholds[entry.pkg] = current
|
||||
}
|
||||
|
||||
// Also preserve thresholds for packages still in file but not in coverage
|
||||
// (e.g., tests excluded in this run); keep them to avoid false passes.
|
||||
if !*update {
|
||||
for k, v := range thresholds {
|
||||
if _, ok := newThresholds[k]; !ok {
|
||||
newThresholds[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(rows, func(i, j int) bool { return rows[i].pkg < rows[j].pkg })
|
||||
|
||||
fmt.Println("Coverage Gate Report")
|
||||
fmt.Println("====================")
|
||||
fmt.Printf("%-60s %8s %8s %8s %s\n", "PACKAGE", "CURRENT", "FLOOR", "DELTA", "STATUS")
|
||||
for _, r := range rows {
|
||||
fmt.Printf("%-60s %8s %8s %+7.1f%% %s\n",
|
||||
r.pkg, pctStr(r.current), pctStr(r.threshold), r.delta, r.status)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
if *update {
|
||||
if err := writeThresholds(*thresholdsPath, newThresholds); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error writing thresholds: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
fmt.Printf("Updated %s with %d package thresholds\n", *thresholdsPath, len(newThresholds))
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
fmt.Printf("FAIL: %d package(s) below threshold\n", failed)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("PASS: all %d package(s) meet threshold\n", len(rows))
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"internal/agent": 36.809815950920246,
|
||||
"internal/backup": 18.803418803418804,
|
||||
"internal/bootstrap": 29.152542372881356,
|
||||
"internal/bus": 30.578512396694215,
|
||||
"internal/cache": 96.875,
|
||||
"internal/channels": 26.524390243902438,
|
||||
"internal/channels/discord": 27.680311890838208,
|
||||
"internal/channels/facebook": 23.076923076923077,
|
||||
"internal/channels/feishu": 0,
|
||||
"internal/channels/media": 9.174311926605505,
|
||||
"internal/channels/pancake": 55.319148936170215,
|
||||
"internal/channels/slack": 19.313850063532403,
|
||||
"internal/channels/telegram": 13.217072051399725,
|
||||
"internal/channels/telegram/voiceguard": 100,
|
||||
"internal/channels/typing": 91.80327868852459,
|
||||
"internal/channels/whatsapp": 21.323529411764707,
|
||||
"internal/channels/zalo": 7.203389830508475,
|
||||
"internal/channels/zalo/personal": 0,
|
||||
"internal/channels/zalo/personal/protocol": 19.56989247311828,
|
||||
"internal/channels/zalo/personal/zalomethods": 0,
|
||||
"internal/config": 48.17275747508306,
|
||||
"internal/consolidation": 73.77049180327869,
|
||||
"internal/cron": 73.71428571428571,
|
||||
"internal/crypto": 75.40983606557377,
|
||||
"internal/edition": 100,
|
||||
"internal/eventbus": 79.59183673469387,
|
||||
"internal/gateway": 15.11056511056511,
|
||||
"internal/gateway/methods": 7.384515289525049,
|
||||
"internal/heartbeat": 12.244897959183673,
|
||||
"internal/http": 12.458820005989818,
|
||||
"internal/i18n": 100,
|
||||
"internal/knowledgegraph": 91.76470588235294,
|
||||
"internal/mcp": 26.271970397779832,
|
||||
"internal/media": 0,
|
||||
"internal/memory": 10.169491525423728,
|
||||
"internal/oauth": 56.17391304347826,
|
||||
"internal/orchestration": 100,
|
||||
"internal/permissions": 98.18181818181819,
|
||||
"internal/pipeline": 76.90417690417691,
|
||||
"internal/providerresolve": 88.88888888888889,
|
||||
"internal/providers": 56.99965433805738,
|
||||
"internal/providers/acp": 0,
|
||||
"internal/safego": 100,
|
||||
"internal/sandbox": 6.593406593406593,
|
||||
"internal/scheduler": 69.1029900332226,
|
||||
"internal/sessions": 94.37751004016064,
|
||||
"internal/skills": 37.5,
|
||||
"internal/store": 25.880281690140844,
|
||||
"internal/store/base": 95.95959595959596,
|
||||
"internal/store/pg": 1.2770943175161102,
|
||||
"internal/tasks": 55.4140127388535,
|
||||
"internal/testutil": 0,
|
||||
"internal/tokencount": 77.17391304347827,
|
||||
"internal/tools": 26.612716763005782,
|
||||
"internal/tracing": 5,
|
||||
"internal/tracing/otelexport": 11.235955056179776,
|
||||
"internal/tts": 0,
|
||||
"internal/upgrade": 0,
|
||||
"internal/vault": 27.379400260756192,
|
||||
"internal/workspace": 87.34177215189874,
|
||||
"pkg/browser": 6.208425720620842
|
||||
}
|
||||
Reference in new issue
Block a user