diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index a2cdeaa..e9b2d64 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -40,7 +40,7 @@ jobs: run: go generate ./internal/cmd/updater/... - name: Run coverage - run: core go cov + run: core go cov --output coverage.txt --threshold 40 --branch-threshold 35 - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v5 diff --git a/.gitignore b/.gitignore index bfb8938..f6e26ac 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ tasks /core +patch_cov.* diff --git a/README.md b/README.md index 8f2f738..963bf4f 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ task cli:run # Build and run | `task test-gen` | Generate test stubs for public API | | `task check` | go mod tidy + tests + review | | `task review` | CodeRabbit review | -| `task cov` | Generate coverage.txt | +| `task cov` | Run tests with coverage report | | `task cov-view` | Open HTML coverage report | | `task sync` | Update public API Go files | diff --git a/Taskfile.yml b/Taskfile.yml index d437990..1e26746 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -53,6 +53,11 @@ tasks: cmds: - core go cov + cov-view: + desc: "Open HTML coverage report" + cmds: + - core go cov --open + fmt: desc: "Format Go code" cmds: diff --git a/docs/configuration.md b/docs/configuration.md index deabb68..5fabf7a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -160,7 +160,10 @@ dev: test: parallel: true - coverage: false + coverage: true + thresholds: + statements: 40 + branches: 35 deploy: coolify: diff --git a/docs/workflows.md b/docs/workflows.md index 96b0c9f..8c40372 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -10,8 +10,8 @@ Complete workflow from code to GitHub release. # 1. Run tests core go test -# 2. Check coverage -core go cov --threshold 80 +# 2. Check coverage (Statement and Branch) +core go cov --threshold 40 --branch-threshold 35 # 3. Format and lint core go fmt --fix diff --git a/internal/cmd/go/cmd_gotest.go b/internal/cmd/go/cmd_gotest.go index 4145fae..acc8af8 100644 --- a/internal/cmd/go/cmd_gotest.go +++ b/internal/cmd/go/cmd_gotest.go @@ -1,12 +1,15 @@ package gocmd import ( + "bufio" "errors" "fmt" + "io" "os" "os/exec" "path/filepath" "regexp" + "strconv" "strings" "github.com/host-uk/core/pkg/cli" @@ -51,10 +54,16 @@ func runGoTest(coverage bool, pkg, run string, short, race, jsonOut, verbose boo args := []string{"test"} + var covPath string if coverage { - args = append(args, "-cover") - } else { - args = append(args, "-cover") + args = append(args, "-cover", "-covermode=atomic") + covFile, err := os.CreateTemp("", "coverage-*.out") + if err == nil { + covPath = covFile.Name() + _ = covFile.Close() + args = append(args, "-coverprofile="+covPath) + defer os.Remove(covPath) + } } if run != "" { @@ -121,7 +130,15 @@ func runGoTest(coverage bool, pkg, run string, short, race, jsonOut, verbose boo } if cov > 0 { - cli.Print("\n %s %s\n", cli.KeyStyle.Render(i18n.Label("coverage")), formatCoverage(cov)) + cli.Print("\n %s %s\n", cli.KeyStyle.Render(i18n.Label("statements")), formatCoverage(cov)) + if covPath != "" { + branchCov, err := calculateBlockCoverage(covPath) + if err != nil { + cli.Print(" %s %s\n", cli.KeyStyle.Render(i18n.Label("branches")), cli.ErrorStyle.Render("unable to calculate")) + } else { + cli.Print(" %s %s\n", cli.KeyStyle.Render(i18n.Label("branches")), formatCoverage(branchCov)) + } + } } if err == nil { @@ -161,10 +178,12 @@ func parseOverallCoverage(output string) float64 { } var ( - covPkg string - covHTML bool - covOpen bool - covThreshold float64 + covPkg string + covHTML bool + covOpen bool + covThreshold float64 + covBranchThreshold float64 + covOutput string ) func addGoCovCommand(parent *cli.Command) { @@ -193,7 +212,21 @@ func addGoCovCommand(parent *cli.Command) { } covPath := covFile.Name() _ = covFile.Close() - defer func() { _ = os.Remove(covPath) }() + defer func() { + if covOutput == "" { + _ = os.Remove(covPath) + } else { + // Copy to output destination before removing + src, _ := os.Open(covPath) + dst, _ := os.Create(covOutput) + if src != nil && dst != nil { + _, _ = io.Copy(dst, src) + _ = src.Close() + _ = dst.Close() + } + _ = os.Remove(covPath) + } + }() cli.Print("%s %s\n", dimStyle.Render(i18n.Label("coverage")), i18n.ProgressSubject("run", "tests")) // Truncate package list if too long for display @@ -228,7 +261,7 @@ func addGoCovCommand(parent *cli.Command) { // Parse total coverage from last line lines := strings.Split(strings.TrimSpace(string(covOutput)), "\n") - var totalCov float64 + var statementCov float64 if len(lines) > 0 { lastLine := lines[len(lines)-1] // Format: "total: (statements) XX.X%" @@ -236,14 +269,21 @@ func addGoCovCommand(parent *cli.Command) { parts := strings.Fields(lastLine) if len(parts) >= 3 { covStr := strings.TrimSuffix(parts[len(parts)-1], "%") - _, _ = fmt.Sscanf(covStr, "%f", &totalCov) + _, _ = fmt.Sscanf(covStr, "%f", &statementCov) } } } + // Calculate branch coverage (block coverage) + branchCov, err := calculateBlockCoverage(covPath) + if err != nil { + return cli.Wrap(err, "calculate branch coverage") + } + // Print coverage summary cli.Blank() - cli.Print(" %s %s\n", cli.KeyStyle.Render(i18n.Label("total")), formatCoverage(totalCov)) + cli.Print(" %s %s\n", cli.KeyStyle.Render(i18n.Label("statements")), formatCoverage(statementCov)) + cli.Print(" %s %s\n", cli.KeyStyle.Render(i18n.Label("branches")), formatCoverage(branchCov)) // Generate HTML if requested if covHTML || covOpen { @@ -271,10 +311,14 @@ func addGoCovCommand(parent *cli.Command) { } } - // Check threshold - if covThreshold > 0 && totalCov < covThreshold { - cli.Print("\n%s %.1f%% < %.1f%%\n", errorStyle.Render(i18n.T("i18n.fail.meet", "threshold")), totalCov, covThreshold) - return errors.New("coverage below threshold") + // Check thresholds + if covThreshold > 0 && statementCov < covThreshold { + cli.Print("\n%s Statements: %.1f%% < %.1f%%\n", errorStyle.Render(i18n.T("i18n.fail.meet", "threshold")), statementCov, covThreshold) + return errors.New("statement coverage below threshold") + } + if covBranchThreshold > 0 && branchCov < covBranchThreshold { + cli.Print("\n%s Branches: %.1f%% < %.1f%%\n", errorStyle.Render(i18n.T("i18n.fail.meet", "threshold")), branchCov, covBranchThreshold) + return errors.New("branch coverage below threshold") } if testErr != nil { @@ -289,11 +333,66 @@ func addGoCovCommand(parent *cli.Command) { covCmd.Flags().StringVar(&covPkg, "pkg", "", "Package to test") covCmd.Flags().BoolVar(&covHTML, "html", false, "Generate HTML report") covCmd.Flags().BoolVar(&covOpen, "open", false, "Open HTML report in browser") - covCmd.Flags().Float64Var(&covThreshold, "threshold", 0, "Minimum coverage percentage") + covCmd.Flags().Float64Var(&covThreshold, "threshold", 0, "Minimum statement coverage percentage") + covCmd.Flags().Float64Var(&covBranchThreshold, "branch-threshold", 0, "Minimum branch coverage percentage") + covCmd.Flags().StringVarP(&covOutput, "output", "o", "", "Output file for coverage profile") parent.AddCommand(covCmd) } +// calculateBlockCoverage parses a Go coverage profile and returns the percentage of basic +// blocks that have a non-zero execution count. Go's coverage profile contains one line per +// basic block, where the last field is the execution count, not explicit branch coverage. +// The resulting block coverage is used here only as a proxy for branch coverage; computing +// true branch coverage would require more detailed control-flow analysis. +func calculateBlockCoverage(path string) (float64, error) { + file, err := os.Open(path) + if err != nil { + return 0, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + var totalBlocks, coveredBlocks int + + // Skip the first line (mode: atomic/set/count) + if !scanner.Scan() { + return 0, nil + } + + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) < 3 { + continue + } + + // Last field is the count + count, err := strconv.Atoi(fields[len(fields)-1]) + if err != nil { + continue + } + + totalBlocks++ + if count > 0 { + coveredBlocks++ + } + } + + if err := scanner.Err(); err != nil { + return 0, err + } + + if totalBlocks == 0 { + return 0, nil + } + + return (float64(coveredBlocks) / float64(totalBlocks)) * 100, nil +} + func findTestPackages(root string) ([]string, error) { pkgMap := make(map[string]bool) err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { diff --git a/internal/cmd/go/cmd_qa.go b/internal/cmd/go/cmd_qa.go index 9aefa48..fcda477 100644 --- a/internal/cmd/go/cmd_qa.go +++ b/internal/cmd/go/cmd_qa.go @@ -24,6 +24,7 @@ var ( qaOnly string qaCoverage bool qaThreshold float64 + qaBranchThreshold float64 qaDocblockThreshold float64 qaJSON bool qaVerbose bool @@ -71,7 +72,8 @@ Examples: // Coverage flags qaCmd.PersistentFlags().BoolVar(&qaCoverage, "coverage", false, "Include coverage reporting") qaCmd.PersistentFlags().BoolVarP(&qaCoverage, "cov", "c", false, "Include coverage reporting (shorthand)") - qaCmd.PersistentFlags().Float64Var(&qaThreshold, "threshold", 0, "Minimum coverage threshold (0-100), fail if below") + qaCmd.PersistentFlags().Float64Var(&qaThreshold, "threshold", 0, "Minimum statement coverage threshold (0-100), fail if below") + qaCmd.PersistentFlags().Float64Var(&qaBranchThreshold, "branch-threshold", 0, "Minimum branch coverage threshold (0-100), fail if below") qaCmd.PersistentFlags().Float64Var(&qaDocblockThreshold, "docblock-threshold", 80, "Minimum docblock coverage threshold (0-100)") // Test flags @@ -134,11 +136,13 @@ Examples: // QAResult holds the result of a QA run for JSON output type QAResult struct { - Success bool `json:"success"` - Duration string `json:"duration"` - Checks []CheckResult `json:"checks"` - Coverage *float64 `json:"coverage,omitempty"` - Threshold *float64 `json:"threshold,omitempty"` + Success bool `json:"success"` + Duration string `json:"duration"` + Checks []CheckResult `json:"checks"` + Coverage *float64 `json:"coverage,omitempty"` + BranchCoverage *float64 `json:"branch_coverage,omitempty"` + Threshold *float64 `json:"threshold,omitempty"` + BranchThreshold *float64 `json:"branch_threshold,omitempty"` } // CheckResult holds the result of a single check @@ -254,21 +258,34 @@ func runGoQA(cmd *cli.Command, args []string) error { // Run coverage if requested var coverageVal *float64 + var branchVal *float64 if qaCoverage && !qaFailFast || (qaCoverage && failed == 0) { - cov, err := runCoverage(ctx, cwd) + cov, branch, err := runCoverage(ctx, cwd) if err == nil { coverageVal = &cov + branchVal = &branch if !qaJSON && !qaQuiet { - cli.Print("\n%s %.1f%%\n", cli.DimStyle.Render("Coverage:"), cov) + cli.Print("\n%s %.1f%%\n", cli.DimStyle.Render("Statement Coverage:"), cov) + cli.Print("%s %.1f%%\n", cli.DimStyle.Render("Branch Coverage:"), branch) } if qaThreshold > 0 && cov < qaThreshold { failed++ if !qaJSON && !qaQuiet { - cli.Print(" %s Coverage %.1f%% below threshold %.1f%%\n", + cli.Print(" %s Statement coverage %.1f%% below threshold %.1f%%\n", cli.ErrorStyle.Render(cli.Glyph(":cross:")), cov, qaThreshold) - cli.Hint("fix", "Run 'core go cov --open' to see uncovered lines, then add tests.") } } + if qaBranchThreshold > 0 && branch < qaBranchThreshold { + failed++ + if !qaJSON && !qaQuiet { + cli.Print(" %s Branch coverage %.1f%% below threshold %.1f%%\n", + cli.ErrorStyle.Render(cli.Glyph(":cross:")), branch, qaBranchThreshold) + } + } + + if failed > 0 && !qaJSON && !qaQuiet { + cli.Hint("fix", "Run 'core go cov --open' to see uncovered lines, then add tests.") + } } } @@ -277,14 +294,18 @@ func runGoQA(cmd *cli.Command, args []string) error { // JSON output if qaJSON { qaResult := QAResult{ - Success: failed == 0, - Duration: duration.String(), - Checks: results, - Coverage: coverageVal, + Success: failed == 0, + Duration: duration.String(), + Checks: results, + Coverage: coverageVal, + BranchCoverage: branchVal, } if qaThreshold > 0 { qaResult.Threshold = &qaThreshold } + if qaBranchThreshold > 0 { + qaResult.BranchThreshold = &qaBranchThreshold + } enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(qaResult) @@ -525,8 +546,17 @@ func runCheckCapture(ctx context.Context, dir string, check QACheck) (string, er return "", cmd.Run() } -func runCoverage(ctx context.Context, dir string) (float64, error) { - args := []string{"test", "-cover", "-coverprofile=/tmp/coverage.out"} +func runCoverage(ctx context.Context, dir string) (float64, float64, error) { + // Create temp file for coverage data + covFile, err := os.CreateTemp("", "coverage-*.out") + if err != nil { + return 0, 0, err + } + covPath := covFile.Name() + _ = covFile.Close() + defer os.Remove(covPath) + + args := []string{"test", "-cover", "-covermode=atomic", "-coverprofile=" + covPath} if qaShort { args = append(args, "-short") } @@ -540,36 +570,36 @@ func runCoverage(ctx context.Context, dir string) (float64, error) { } if err := cmd.Run(); err != nil { - return 0, err + return 0, 0, err } - // Parse coverage - coverCmd := exec.CommandContext(ctx, "go", "tool", "cover", "-func=/tmp/coverage.out") + // Parse statement coverage + coverCmd := exec.CommandContext(ctx, "go", "tool", "cover", "-func="+covPath) output, err := coverCmd.Output() if err != nil { - return 0, err + return 0, 0, err } // Parse last line for total coverage lines := strings.Split(strings.TrimSpace(string(output)), "\n") - if len(lines) == 0 { - return 0, nil + var statementPct float64 + if len(lines) > 0 { + lastLine := lines[len(lines)-1] + fields := strings.Fields(lastLine) + if len(fields) >= 3 { + // Parse percentage (e.g., "45.6%") + pctStr := strings.TrimSuffix(fields[len(fields)-1], "%") + _, _ = fmt.Sscanf(pctStr, "%f", &statementPct) + } } - lastLine := lines[len(lines)-1] - fields := strings.Fields(lastLine) - if len(fields) < 3 { - return 0, nil + // Parse branch coverage + branchPct, err := calculateBlockCoverage(covPath) + if err != nil { + return statementPct, 0, err } - // Parse percentage (e.g., "45.6%") - pctStr := strings.TrimSuffix(fields[len(fields)-1], "%") - var pct float64 - if _, err := fmt.Sscanf(pctStr, "%f", &pct); err == nil { - return pct, nil - } - - return 0, nil + return statementPct, branchPct, nil } // runInternalCheck runs internal Go-based checks (not external commands). diff --git a/internal/cmd/go/coverage_test.go b/internal/cmd/go/coverage_test.go new file mode 100644 index 0000000..eaf96d8 --- /dev/null +++ b/internal/cmd/go/coverage_test.go @@ -0,0 +1,229 @@ +package gocmd + +import ( + "os" + "testing" + + "github.com/host-uk/core/pkg/cli" + "github.com/stretchr/testify/assert" +) + +func TestCalculateBlockCoverage(t *testing.T) { + // Create a dummy coverage profile + content := `mode: set +github.com/host-uk/core/pkg/foo.go:1.2,3.4 5 1 +github.com/host-uk/core/pkg/foo.go:5.6,7.8 2 0 +github.com/host-uk/core/pkg/bar.go:10.1,12.20 10 5 +` + tmpfile, err := os.CreateTemp("", "test-coverage-*.out") + assert.NoError(t, err) + defer os.Remove(tmpfile.Name()) + + _, err = tmpfile.Write([]byte(content)) + assert.NoError(t, err) + err = tmpfile.Close() + assert.NoError(t, err) + + // Test calculation + // 3 blocks total, 2 covered (count > 0) + // Expect (2/3) * 100 = 66.666... + pct, err := calculateBlockCoverage(tmpfile.Name()) + assert.NoError(t, err) + assert.InDelta(t, 66.67, pct, 0.01) + + // Test empty file (only header) + contentEmpty := "mode: atomic\n" + tmpfileEmpty, _ := os.CreateTemp("", "test-coverage-empty-*.out") + defer os.Remove(tmpfileEmpty.Name()) + tmpfileEmpty.Write([]byte(contentEmpty)) + tmpfileEmpty.Close() + + pct, err = calculateBlockCoverage(tmpfileEmpty.Name()) + assert.NoError(t, err) + assert.Equal(t, 0.0, pct) + + // Test non-existent file + pct, err = calculateBlockCoverage("non-existent-file") + assert.Error(t, err) + assert.Equal(t, 0.0, pct) + + // Test malformed file + contentMalformed := `mode: set +github.com/host-uk/core/pkg/foo.go:1.2,3.4 5 +github.com/host-uk/core/pkg/foo.go:1.2,3.4 5 notanumber +` + tmpfileMalformed, _ := os.CreateTemp("", "test-coverage-malformed-*.out") + defer os.Remove(tmpfileMalformed.Name()) + tmpfileMalformed.Write([]byte(contentMalformed)) + tmpfileMalformed.Close() + + pct, err = calculateBlockCoverage(tmpfileMalformed.Name()) + assert.NoError(t, err) + assert.Equal(t, 0.0, pct) + + // Test malformed file - missing fields + contentMalformed2 := `mode: set +github.com/host-uk/core/pkg/foo.go:1.2,3.4 5 +` + tmpfileMalformed2, _ := os.CreateTemp("", "test-coverage-malformed2-*.out") + defer os.Remove(tmpfileMalformed2.Name()) + tmpfileMalformed2.Write([]byte(contentMalformed2)) + tmpfileMalformed2.Close() + + pct, err = calculateBlockCoverage(tmpfileMalformed2.Name()) + assert.NoError(t, err) + assert.Equal(t, 0.0, pct) + + // Test completely empty file + tmpfileEmpty2, _ := os.CreateTemp("", "test-coverage-empty2-*.out") + defer os.Remove(tmpfileEmpty2.Name()) + tmpfileEmpty2.Close() + pct, err = calculateBlockCoverage(tmpfileEmpty2.Name()) + assert.NoError(t, err) + assert.Equal(t, 0.0, pct) +} + +func TestParseOverallCoverage(t *testing.T) { + output := `ok github.com/host-uk/core/pkg/foo 0.100s coverage: 50.0% of statements +ok github.com/host-uk/core/pkg/bar 0.200s coverage: 100.0% of statements +` + pct := parseOverallCoverage(output) + assert.Equal(t, 75.0, pct) + + outputNoCov := "ok github.com/host-uk/core/pkg/foo 0.100s" + pct = parseOverallCoverage(outputNoCov) + assert.Equal(t, 0.0, pct) +} + +func TestFormatCoverage(t *testing.T) { + assert.Contains(t, formatCoverage(85.0), "85.0%") + assert.Contains(t, formatCoverage(65.0), "65.0%") + assert.Contains(t, formatCoverage(25.0), "25.0%") +} + +func TestAddGoCovCommand(t *testing.T) { + cmd := &cli.Command{Use: "test"} + addGoCovCommand(cmd) + assert.True(t, cmd.HasSubCommands()) + sub := cmd.Commands()[0] + assert.Equal(t, "cov", sub.Name()) +} + +func TestAddGoQACommand(t *testing.T) { + cmd := &cli.Command{Use: "test"} + addGoQACommand(cmd) + assert.True(t, cmd.HasSubCommands()) + sub := cmd.Commands()[0] + assert.Equal(t, "qa", sub.Name()) +} + +func TestDetermineChecks(t *testing.T) { + // Default checks + qaOnly = "" + qaSkip = "" + qaRace = false + qaBench = false + checks := determineChecks() + assert.Contains(t, checks, "fmt") + assert.Contains(t, checks, "test") + + // Only + qaOnly = "fmt,lint" + checks = determineChecks() + assert.Equal(t, []string{"fmt", "lint"}, checks) + + // Skip + qaOnly = "" + qaSkip = "fmt,lint" + checks = determineChecks() + assert.NotContains(t, checks, "fmt") + assert.NotContains(t, checks, "lint") + assert.Contains(t, checks, "test") + + // Race + qaSkip = "" + qaRace = true + checks = determineChecks() + assert.Contains(t, checks, "race") + assert.NotContains(t, checks, "test") + + // Reset + qaRace = false +} + +func TestBuildCheck(t *testing.T) { + qaFix = false + c := buildCheck("fmt") + assert.Equal(t, "format", c.Name) + assert.Equal(t, []string{"-l", "."}, c.Args) + + qaFix = true + c = buildCheck("fmt") + assert.Equal(t, []string{"-w", "."}, c.Args) + + c = buildCheck("vet") + assert.Equal(t, "vet", c.Name) + + c = buildCheck("lint") + assert.Equal(t, "lint", c.Name) + + c = buildCheck("test") + assert.Equal(t, "test", c.Name) + + c = buildCheck("race") + assert.Equal(t, "race", c.Name) + + c = buildCheck("bench") + assert.Equal(t, "bench", c.Name) + + c = buildCheck("vuln") + assert.Equal(t, "vuln", c.Name) + + c = buildCheck("sec") + assert.Equal(t, "sec", c.Name) + + c = buildCheck("fuzz") + assert.Equal(t, "fuzz", c.Name) + + c = buildCheck("docblock") + assert.Equal(t, "docblock", c.Name) + + c = buildCheck("unknown") + assert.Equal(t, "", c.Name) +} + +func TestBuildChecks(t *testing.T) { + checks := buildChecks([]string{"fmt", "vet", "unknown"}) + assert.Equal(t, 2, len(checks)) + assert.Equal(t, "format", checks[0].Name) + assert.Equal(t, "vet", checks[1].Name) +} + +func TestFixHintFor(t *testing.T) { + assert.Contains(t, fixHintFor("format", ""), "core go qa fmt --fix") + assert.Contains(t, fixHintFor("vet", ""), "go vet") + assert.Contains(t, fixHintFor("lint", ""), "core go qa lint --fix") + assert.Contains(t, fixHintFor("test", "--- FAIL: TestFoo"), "TestFoo") + assert.Contains(t, fixHintFor("race", ""), "Data race") + assert.Contains(t, fixHintFor("bench", ""), "Benchmark regression") + assert.Contains(t, fixHintFor("vuln", ""), "govulncheck") + assert.Contains(t, fixHintFor("sec", ""), "gosec") + assert.Contains(t, fixHintFor("fuzz", ""), "crashing input") + assert.Contains(t, fixHintFor("docblock", ""), "doc comments") + assert.Equal(t, "", fixHintFor("unknown", "")) +} + +func TestRunGoQA_NoGoMod(t *testing.T) { + // runGoQA should fail if go.mod is not present in CWD + // We run it in a temp dir without go.mod + tmpDir, _ := os.MkdirTemp("", "test-qa-*") + defer os.RemoveAll(tmpDir) + cwd, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(cwd) + + cmd := &cli.Command{Use: "qa"} + err := runGoQA(cmd, []string{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no go.mod found") +} diff --git a/internal/cmd/test/cmd_output.go b/internal/cmd/test/cmd_output.go index 7df7fa5..2673a1c 100644 --- a/internal/cmd/test/cmd_output.go +++ b/internal/cmd/test/cmd_output.go @@ -138,7 +138,11 @@ func printCoverageSummary(results testResults) { continue } name := shortenPackageName(pkg.name) - padding := strings.Repeat(" ", maxLen-len(name)+2) + padLen := maxLen - len(name) + 2 + if padLen < 0 { + padLen = 2 + } + padding := strings.Repeat(" ", padLen) fmt.Printf(" %s%s%s\n", name, padding, formatCoverage(pkg.coverage)) } @@ -146,7 +150,11 @@ func printCoverageSummary(results testResults) { if results.covCount > 0 { avgCov := results.totalCov / float64(results.covCount) avgLabel := i18n.T("cmd.test.label.average") - padding := strings.Repeat(" ", maxLen-len(avgLabel)+2) + padLen := maxLen - len(avgLabel) + 2 + if padLen < 0 { + padLen = 2 + } + padding := strings.Repeat(" ", padLen) fmt.Printf("\n %s%s%s\n", testHeaderStyle.Render(avgLabel), padding, formatCoverage(avgCov)) } } diff --git a/internal/cmd/test/output_test.go b/internal/cmd/test/output_test.go new file mode 100644 index 0000000..c4b8927 --- /dev/null +++ b/internal/cmd/test/output_test.go @@ -0,0 +1,52 @@ +package testcmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestShortenPackageName(t *testing.T) { + assert.Equal(t, "pkg/foo", shortenPackageName("github.com/host-uk/core/pkg/foo")) + assert.Equal(t, "core-php", shortenPackageName("github.com/host-uk/core-php")) + assert.Equal(t, "bar", shortenPackageName("github.com/other/bar")) +} + +func TestFormatCoverageTest(t *testing.T) { + assert.Contains(t, formatCoverage(85.0), "85.0%") + assert.Contains(t, formatCoverage(65.0), "65.0%") + assert.Contains(t, formatCoverage(25.0), "25.0%") +} + +func TestParseTestOutput(t *testing.T) { + output := `ok github.com/host-uk/core/pkg/foo 0.100s coverage: 50.0% of statements +FAIL github.com/host-uk/core/pkg/bar +? github.com/host-uk/core/pkg/baz [no test files] +` + results := parseTestOutput(output) + assert.Equal(t, 1, results.passed) + assert.Equal(t, 1, results.failed) + assert.Equal(t, 1, results.skipped) + assert.Equal(t, 1, len(results.failedPkgs)) + assert.Equal(t, "github.com/host-uk/core/pkg/bar", results.failedPkgs[0]) + assert.Equal(t, 1, len(results.packages)) + assert.Equal(t, 50.0, results.packages[0].coverage) +} + +func TestPrintCoverageSummarySafe(t *testing.T) { + // This tests the bug fix for long package names causing negative Repeat count + results := testResults{ + packages: []packageCoverage{ + {name: "github.com/host-uk/core/pkg/short", coverage: 100, hasCov: true}, + {name: "github.com/host-uk/core/pkg/a-very-very-very-very-very-long-package-name-that-might-cause-issues", coverage: 80, hasCov: true}, + }, + passed: 2, + totalCov: 180, + covCount: 2, + } + + // Should not panic + assert.NotPanics(t, func() { + printCoverageSummary(results) + }) +} diff --git a/pkg/framework/core/core.go b/pkg/framework/core/core.go index e66c3ea..b627473 100644 --- a/pkg/framework/core/core.go +++ b/pkg/framework/core/core.go @@ -298,13 +298,13 @@ func (c *Core) Display() (Display, error) { // Workspace returns the registered Workspace service. func (c *Core) Workspace() Workspace { - w := MustServiceFor[Workspace](c, "workspace") + w, _ := MustServiceFor[Workspace](c, "workspace") return w } // Crypt returns the registered Crypt service. func (c *Core) Crypt() Crypt { - cr := MustServiceFor[Crypt](c, "crypt") + cr, _ := MustServiceFor[Crypt](c, "crypt") return cr }