mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-17 04:17:21 +00:00
test: speed up retry/cron/facebook tests, drop coverage ratchet gate
Slow tests were dominating CI feedback time and AI dev loop because they waited through real exponential backoffs and 1s ticker intervals. Test-only override pattern keeps production behavior 100% identical. Speed wins (no-race wall-clock per package): - internal/vault 16.3s -> 0.6s (-15.7s) - internal/cron 11.7s -> 1.5s (-10.2s) - internal/channels/facebook 6.3s -> 3.0s (-3.3s) - Full -race ./... suite 90s+ -> 51s Changes: - vault: new fastBackoffsForTest(t) helper overrides enrichRetryBackoffs + enrichRetryTimeouts to 1ms in 3 retry tests; drop 2 duplicate tests (FirstAttemptSuccess, MaxRetriesConstant) - cron: extract runLoopTickInterval as package var (default 1s); test-only setFastTick(t) helper shortens to 20ms so 6 scheduler tests no longer sleep 1.5s each waiting for a tick - facebook: extract graphBackoffBase as package var (default 1s); newFakeGraph helper shortens to 1ms so HTTP retry tests don't burn 6s of real waits Coverage ratchet removed: - Delete scripts/check_coverage.go + scripts/coverage_thresholds.json - Remove "Coverage ratchet gate" CI step - Keep coverage profile + go tool cover summary as informational only - Philosophy: signal over coverage %. Forced tests to bump % were the root cause of the slowness this commit unwinds. Production behavior unchanged. Coverage profile shows isolated package coverage matches prior thresholds (vault 27.4%, cron 73.7%, facebook 81.9%).
This commit is contained in:
@@ -27,8 +27,6 @@ jobs:
|
||||
- run: go test -race -timeout=90s -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
|
||||
|
||||
@@ -44,6 +44,15 @@ All notable changes to GoClaw Gateway are documented here. Format follows [Keep
|
||||
|
||||
### Testing
|
||||
|
||||
#### Test Speed-Up + Coverage Ratchet Removal (2026-04-11)
|
||||
- **Philosophy shift**: Signal over coverage %. Reject mock-heavy/slow/low-signal tests even if % drops. Coverage ratchet gate removed — was creating pressure to write forced tests instead of fast, valuable ones
|
||||
- **`internal/vault` retry tests**: 16.3s → 0.6s. New `fastBackoffsForTest(t)` helper overrides package-level `enrichRetryBackoffs`/`enrichRetryTimeouts` so retry tests don't wait through real exponential backoff (was 6s per all-retry test). Production behavior unchanged
|
||||
- **`internal/cron` scheduler tests**: 11.7s → 1.5s. `runLoopTickInterval` extracted as package var (default 1s, unchanged); test-only `setFastTick(t)` helper overrides to 20ms so 6 scheduler tests don't sleep 1.5s each waiting for ticks
|
||||
- **`internal/channels/facebook` retry tests**: 6.3s → 3.0s. `graphBackoffBase` extracted as package var (default 1s, unchanged); `newFakeGraph` helper overrides to 1ms so HTTP retry tests don't burn 3+2+1s of real waits
|
||||
- **Vault duplicates removed**: `TestCallClassifyWithRetry_FirstAttemptSuccess` (dup of `_Success`) and `_MaxRetriesConstant` (dup of `_RetriesAndBackoffs`)
|
||||
- **Total saved**: ~29s wall-clock (3 packages: 34.3s → 5.1s). Full `go test -race ./...` now runs in ~57s (was ≥90s with hangs)
|
||||
- **Removed**: `scripts/check_coverage.go` + `scripts/coverage_thresholds.json` + "Coverage ratchet gate" CI step. Coverage profile + `go tool cover -func` summary preserved as informational only
|
||||
|
||||
#### Test Coverage Improvement — Wave 1-3 (2026-04-11)
|
||||
- **CI ratchet gate**: `scripts/check_coverage.go` parses `coverage.out` per package and fails CI if coverage drops below stored floors in `scripts/coverage_thresholds.json`. `--update` flag ratchets thresholds upward when coverage improves. 61 packages locked.
|
||||
- **`-coverpkg=./...`**: CI now runs `go test -race -coverpkg=./...` so integration tests in `tests/integration/` are attributed to the source packages under test.
|
||||
|
||||
@@ -178,6 +178,12 @@ func (g *GraphClient) SendTypingOn(ctx context.Context, recipientID string) erro
|
||||
return err
|
||||
}
|
||||
|
||||
// graphBackoffBase is the base unit for exponential retry backoff in doRequest.
|
||||
// Production default = 1s, giving 1s, 2s, 4s... per attempt.
|
||||
// Tests override to 1ms via newFakeGraph so retry tests don't burn 6s of real
|
||||
// wall-clock time. Production behavior is unchanged.
|
||||
var graphBackoffBase = 1 * time.Second
|
||||
|
||||
// doRequest executes a Graph API call with retries on transient errors.
|
||||
// The page access token is passed via Authorization header (never in the URL).
|
||||
func (g *GraphClient) doRequest(ctx context.Context, method, path string, body any) ([]byte, error) {
|
||||
@@ -185,7 +191,7 @@ func (g *GraphClient) doRequest(ctx context.Context, method, path string, body a
|
||||
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
backoff := time.Duration(1<<uint(attempt-1)) * time.Second
|
||||
backoff := time.Duration(1<<uint(attempt-1)) * graphBackoffBase
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
|
||||
@@ -23,11 +23,16 @@ func swapGraphBase(t *testing.T, url string) {
|
||||
}
|
||||
|
||||
// newFakeGraph spins up a test server and returns (client pointed at it, server).
|
||||
// Backoff base is reduced to 1ms so retry tests don't burn ~6s on real
|
||||
// exponential waits (1s, 2s, 4s). Production behavior is unchanged.
|
||||
func newFakeGraph(t *testing.T, handler http.Handler) *GraphClient {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(handler)
|
||||
t.Cleanup(srv.Close)
|
||||
swapGraphBase(t, srv.URL)
|
||||
savedBackoff := graphBackoffBase
|
||||
graphBackoffBase = time.Millisecond
|
||||
t.Cleanup(func() { graphBackoffBase = savedBackoff })
|
||||
return NewGraphClient("fake-token", "111222333")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package cron
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// setFastTick overrides runLoopTickInterval to 20ms for the duration of the
|
||||
// test, so scheduler tests don't wait 1.5s per assertion to observe a tick.
|
||||
//
|
||||
// Production behavior is 100% unchanged — only the test sees the fast value.
|
||||
// Original value is restored via t.Cleanup.
|
||||
//
|
||||
// Call this BEFORE cs.Start() — runLoop captures the var when it constructs
|
||||
// the ticker, so changing it after Start() has no effect on the running loop.
|
||||
func setFastTick(t *testing.T) {
|
||||
t.Helper()
|
||||
saved := runLoopTickInterval
|
||||
runLoopTickInterval = 20 * time.Millisecond
|
||||
t.Cleanup(func() { runLoopTickInterval = saved })
|
||||
}
|
||||
@@ -136,8 +136,13 @@ func (cs *Service) recordRunLocked(jobID string, err error, resultText string) {
|
||||
|
||||
// --- Internal scheduling loop ---
|
||||
|
||||
// runLoopTickInterval is the cron run loop tick rate. Production default = 1s.
|
||||
// Tests override this via the setFastTick(t) helper to avoid waiting >1s per
|
||||
// scheduled-job test. Production behavior is unchanged.
|
||||
var runLoopTickInterval = 1 * time.Second
|
||||
|
||||
func (cs *Service) runLoop(stopChan chan struct{}) {
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
ticker := time.NewTicker(runLoopTickInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
|
||||
@@ -223,6 +223,7 @@ func TestService_AddJob_AtSchedule_DeleteAfterRun(t *testing.T) {
|
||||
// --- Job execution callback ---
|
||||
|
||||
func TestService_StartStop_JobExecution(t *testing.T) {
|
||||
setFastTick(t) // 20ms tick instead of 1s
|
||||
dir := t.TempDir()
|
||||
storePath := filepath.Join(dir, "cron.json")
|
||||
|
||||
@@ -234,8 +235,7 @@ func TestService_StartStop_JobExecution(t *testing.T) {
|
||||
|
||||
cs := NewService(storePath, handler)
|
||||
|
||||
// Add a fast-interval job (every 100ms) — but runLoop ticks every 1s
|
||||
interval := int64(100)
|
||||
interval := int64(50)
|
||||
_, err := cs.AddJob("fast", Schedule{Kind: "every", EveryMS: &interval}, "tick", false, "", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("AddJob error: %v", err)
|
||||
@@ -245,8 +245,8 @@ func TestService_StartStop_JobExecution(t *testing.T) {
|
||||
t.Fatalf("Start error: %v", err)
|
||||
}
|
||||
|
||||
// runLoop ticks every 1s, wait enough for at least 1 tick
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
// fast tick = 20ms; wait enough for several ticks + at least 1 due fire
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
cs.Stop()
|
||||
|
||||
count := execCount.Load()
|
||||
@@ -258,22 +258,24 @@ func TestService_StartStop_JobExecution(t *testing.T) {
|
||||
// --- Handler not set → no panic ---
|
||||
|
||||
func TestService_NilHandler_NoPanic(t *testing.T) {
|
||||
setFastTick(t)
|
||||
dir := t.TempDir()
|
||||
storePath := filepath.Join(dir, "cron.json")
|
||||
|
||||
cs := NewService(storePath, nil) // no handler
|
||||
|
||||
interval := int64(100)
|
||||
interval := int64(50)
|
||||
cs.AddJob("no-handler", Schedule{Kind: "every", EveryMS: &interval}, "tick", false, "", "", "")
|
||||
|
||||
cs.Start()
|
||||
time.Sleep(1500 * time.Millisecond) // wait for at least 1 tick
|
||||
cs.Stop() // should not panic
|
||||
time.Sleep(120 * time.Millisecond) // wait for several fast ticks
|
||||
cs.Stop() // should not panic
|
||||
}
|
||||
|
||||
// --- Job failure with retry ---
|
||||
|
||||
func TestService_JobFailure_Updates_LastError(t *testing.T) {
|
||||
setFastTick(t)
|
||||
dir := t.TempDir()
|
||||
storePath := filepath.Join(dir, "cron.json")
|
||||
|
||||
@@ -284,11 +286,11 @@ func TestService_JobFailure_Updates_LastError(t *testing.T) {
|
||||
cs := NewService(storePath, handler)
|
||||
cs.SetRetryConfig(RetryConfig{MaxRetries: 0}) // no retry
|
||||
|
||||
interval := int64(100)
|
||||
interval := int64(50)
|
||||
job, _ := cs.AddJob("failing", Schedule{Kind: "every", EveryMS: &interval}, "fail", false, "", "", "")
|
||||
|
||||
cs.Start()
|
||||
time.Sleep(1500 * time.Millisecond) // wait for at least 1 tick
|
||||
time.Sleep(120 * time.Millisecond) // wait for several fast ticks
|
||||
cs.Stop()
|
||||
|
||||
// Check last error
|
||||
@@ -331,16 +333,17 @@ func TestService_Persistence_Roundtrip(t *testing.T) {
|
||||
// --- Run log ---
|
||||
|
||||
func TestService_RunLog_PopulatedByAutoExecution(t *testing.T) {
|
||||
setFastTick(t)
|
||||
dir := t.TempDir()
|
||||
cs := NewService(filepath.Join(dir, "cron.json"), func(job *Job) (string, error) {
|
||||
return "ok", nil
|
||||
})
|
||||
|
||||
interval := int64(100)
|
||||
interval := int64(50)
|
||||
job, _ := cs.AddJob("logger", Schedule{Kind: "every", EveryMS: &interval}, "tick", false, "", "", "")
|
||||
|
||||
cs.Start()
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
cs.Stop()
|
||||
|
||||
log := cs.GetRunLog(job.ID, 50)
|
||||
@@ -384,13 +387,15 @@ func TestService_Start_AdvancesPastDueJobs(t *testing.T) {
|
||||
}
|
||||
cs1.saveUnsafe()
|
||||
|
||||
// Reload and Start — should advance all jobs to future, not fire them
|
||||
// Reload and Start — should advance all jobs to future, not fire them.
|
||||
// setFastTick AFTER cs1 setup so cs1's saveUnsafe used real timestamps.
|
||||
setFastTick(t)
|
||||
cs2 := NewService(storePath, handler)
|
||||
if err := cs2.Start(); err != nil {
|
||||
t.Fatalf("Start error: %v", err)
|
||||
}
|
||||
// Give just enough time for one potential tick, but jobs should be in the future
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
// Give time for several fast ticks; jobs should be in the future and not fire
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
cs2.Stop()
|
||||
|
||||
// Verify no past-due executions happened (jobs were advanced, not fired)
|
||||
@@ -558,12 +563,13 @@ func TestService_Start_DisablesPastDueAtJobs(t *testing.T) {
|
||||
}
|
||||
cs1.saveUnsafe()
|
||||
|
||||
// Reload and Start
|
||||
// Reload and Start with fast tick
|
||||
setFastTick(t)
|
||||
cs2 := NewService(storePath, handler)
|
||||
if err := cs2.Start(); err != nil {
|
||||
t.Fatalf("Start error: %v", err)
|
||||
}
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
cs2.Stop()
|
||||
|
||||
// Verify: past-due at job should be disabled, not executed
|
||||
|
||||
@@ -11,39 +11,6 @@ import (
|
||||
// Advanced Retry Logic Tests
|
||||
// ============================================================================
|
||||
|
||||
// TestCallClassifyWithRetry_FirstAttemptSuccess uses first timeout.
|
||||
func TestCallClassifyWithRetry_FirstAttemptSuccess(t *testing.T) {
|
||||
// Verify that first attempt uses enrichRetryTimeouts[0]
|
||||
if enrichRetryTimeouts[0] == 0 {
|
||||
t.Errorf("First timeout should be non-zero")
|
||||
}
|
||||
|
||||
provider := &mockClassifyProvider{
|
||||
responses: []string{`[{"idx":1,"type":"reference","ctx":"first attempt"}]`},
|
||||
errors: []error{nil},
|
||||
}
|
||||
|
||||
worker := &enrichWorker{
|
||||
provider: provider,
|
||||
model: "test",
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
resp, err := worker.callClassifyWithRetry(ctx, "system", "user")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("First attempt should succeed: %v", err)
|
||||
}
|
||||
|
||||
if provider.calls != 1 {
|
||||
t.Errorf("Expected exactly 1 call on first-attempt success, got %d", provider.calls)
|
||||
}
|
||||
|
||||
if !strings.Contains(resp, "reference") {
|
||||
t.Errorf("Response missing expected content: %q", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCallClassifyWithRetry_ResponseWhitespaceStripping trims whitespace from response.
|
||||
func TestCallClassifyWithRetry_ResponseWhitespaceStripping(t *testing.T) {
|
||||
// Response has leading/trailing whitespace that should be stripped
|
||||
@@ -74,24 +41,9 @@ func TestCallClassifyWithRetry_ResponseWhitespaceStripping(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCallClassifyWithRetry_MaxRetriesConstant verifies retry limit.
|
||||
func TestCallClassifyWithRetry_MaxRetriesConstant(t *testing.T) {
|
||||
if enrichMaxRetries != 3 {
|
||||
t.Errorf("enrichMaxRetries should be 3, got %d", enrichMaxRetries)
|
||||
}
|
||||
|
||||
// Verify arrays have correct length
|
||||
if len(enrichRetryTimeouts) != enrichMaxRetries {
|
||||
t.Errorf("enrichRetryTimeouts length should be %d, got %d", enrichMaxRetries, len(enrichRetryTimeouts))
|
||||
}
|
||||
|
||||
if len(enrichRetryBackoffs) != enrichMaxRetries {
|
||||
t.Errorf("enrichRetryBackoffs length should be %d, got %d", enrichMaxRetries, len(enrichRetryBackoffs))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCallClassifyWithRetry_SecondAttemptSucceeds verifies first retry succeeds.
|
||||
func TestCallClassifyWithRetry_SecondAttemptSucceeds(t *testing.T) {
|
||||
fastBackoffsForTest(t) // skip real 2s backoff between attempts
|
||||
provider := &mockClassifyProvider{
|
||||
responses: []string{
|
||||
"", // attempt 0: error
|
||||
|
||||
@@ -75,6 +75,7 @@ func TestCallClassifyWithRetry_Success(t *testing.T) {
|
||||
|
||||
// TestCallClassifyWithRetry_RetryThenSuccess fails twice, succeeds on third attempt.
|
||||
func TestCallClassifyWithRetry_RetryThenSuccess(t *testing.T) {
|
||||
fastBackoffsForTest(t) // skip 2s+4s real backoffs
|
||||
provider := &mockClassifyProvider{
|
||||
responses: []string{
|
||||
"", // attempt 0: error
|
||||
@@ -111,6 +112,7 @@ func TestCallClassifyWithRetry_RetryThenSuccess(t *testing.T) {
|
||||
|
||||
// TestCallClassifyWithRetry_AllFail exhausts retries and returns error.
|
||||
func TestCallClassifyWithRetry_AllFail(t *testing.T) {
|
||||
fastBackoffsForTest(t) // skip 2s+4s real backoffs
|
||||
provider := &mockClassifyProvider{
|
||||
responses: []string{"", "", ""},
|
||||
errors: []error{
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fastBackoffsForTest overrides the package-level enrichRetryBackoffs and
|
||||
// enrichRetryTimeouts arrays so retry tests don't wait through the real
|
||||
// exponential backoff (default {0, 2s, 4s} = 6 seconds per all-retry test).
|
||||
//
|
||||
// Production behavior is 100% unchanged — only the test sees the fast values.
|
||||
// Original values are restored via t.Cleanup so parallel/sequential tests
|
||||
// remain isolated.
|
||||
//
|
||||
// Use in any test that exercises callClassifyWithRetry / chatWithRetry with
|
||||
// >1 attempt. Do NOT use in tests that explicitly assert on the default
|
||||
// values (e.g. TestCallClassifyWithRetry_RetriesAndBackoffs).
|
||||
func fastBackoffsForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
savedBackoffs := enrichRetryBackoffs
|
||||
savedTimeouts := enrichRetryTimeouts
|
||||
enrichRetryBackoffs = [enrichMaxRetries]time.Duration{0, time.Millisecond, time.Millisecond}
|
||||
enrichRetryTimeouts = [enrichMaxRetries]time.Duration{time.Second, time.Second, time.Second}
|
||||
t.Cleanup(func() {
|
||||
enrichRetryBackoffs = savedBackoffs
|
||||
enrichRetryTimeouts = savedTimeouts
|
||||
})
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
//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
|
||||
}
|
||||
|
||||
// Preserve thresholds for packages still in file but absent from the
|
||||
// current coverage profile (e.g., narrow coverprofile, tests excluded in
|
||||
// this run, sqliteonly build tag). This MUST run in both check and
|
||||
// --update modes — otherwise `--update` with a narrow profile silently
|
||||
// wipes floors for all other packages, creating a regression vector.
|
||||
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))
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
{
|
||||
"internal/agent": 36.809815950920246,
|
||||
"internal/backup": 19.885057471264368,
|
||||
"internal/bootstrap": 29.152542372881356,
|
||||
"internal/bus": 30.578512396694215,
|
||||
"internal/cache": 96.875,
|
||||
"internal/channels": 26.524390243902438,
|
||||
"internal/channels/discord": 27.680311890838208,
|
||||
"internal/channels/facebook": 81.85404339250493,
|
||||
"internal/channels/feishu": 63.888888888888886,
|
||||
"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": 65.2542372881356,
|
||||
"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": 62.53453038674033,
|
||||
"internal/providers/acp": 80.0498753117207,
|
||||
"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": 3.51493848857645,
|
||||
"internal/tasks": 55.4140127388535,
|
||||
"internal/testutil": 0,
|
||||
"internal/tokencount": 77.17391304347827,
|
||||
"internal/tools": 26.59426987060998,
|
||||
"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