diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c0518896..251b70e2 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -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 diff --git a/internal/testutil/context.go b/internal/testutil/context.go new file mode 100644 index 00000000..6dfecee3 --- /dev/null +++ b/internal/testutil/context.go @@ -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 +} diff --git a/internal/testutil/db.go b/internal/testutil/db.go new file mode 100644 index 00000000..7b76da30 --- /dev/null +++ b/internal/testutil/db.go @@ -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 +} diff --git a/internal/testutil/doc.go b/internal/testutil/doc.go new file mode 100644 index 00000000..833a79f1 --- /dev/null +++ b/internal/testutil/doc.go @@ -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 diff --git a/internal/testutil/generate.go b/internal/testutil/generate.go new file mode 100644 index 00000000..ec5f48ed --- /dev/null +++ b/internal/testutil/generate.go @@ -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 diff --git a/scripts/check_coverage.go b/scripts/check_coverage.go new file mode 100644 index 00000000..eb664928 --- /dev/null +++ b/scripts/check_coverage.go @@ -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)) +} diff --git a/scripts/coverage_thresholds.json b/scripts/coverage_thresholds.json new file mode 100644 index 00000000..cf7ab03f --- /dev/null +++ b/scripts/coverage_thresholds.json @@ -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 +}