Skip to content

Commit 523ec80

Browse files
feat(coverage): Add code coverage collection and export (#216)
1 parent 5fbdb03 commit 523ec80

16 files changed

Lines changed: 2240 additions & 14 deletions

File tree

cmd/run.go

Lines changed: 121 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ var (
5656
// Validation mode
5757
validateSuiteIfDefaultBranch bool
5858
validateSuite bool
59+
60+
// Coverage mode
61+
showCoverage bool
62+
coverageOutputPath string
5963
)
6064

6165
//go:embed short_docs/drift/drift_run.md
@@ -116,6 +120,10 @@ func bindRunFlags(cmd *cobra.Command) {
116120
cmd.Flags().BoolVar(&validateSuiteIfDefaultBranch, "validate-suite-if-default-branch", false, "[Cloud] Validate traces on default branch before adding to suite")
117121
cmd.Flags().BoolVar(&validateSuite, "validate-suite", false, "[Cloud] Force validation mode regardless of branch")
118122

123+
// Coverage mode
124+
cmd.Flags().BoolVar(&showCoverage, "show-coverage", false, "Collect and display code coverage during test execution")
125+
cmd.Flags().StringVar(&coverageOutputPath, "coverage-output", "", "Write coverage data to file (LCOV by default, JSON if path ends in .json)")
126+
119127
_ = cmd.Flags().MarkHidden("client-id")
120128
cmd.Flags().SortFlags = false
121129
}
@@ -239,11 +247,12 @@ func runTests(cmd *cobra.Command, args []string) error {
239247
var req *backend.CreateDriftRunRequest
240248

241249
if isValidation {
250+
commitSha = getCommitSHAFromEnv()
242251
req = &backend.CreateDriftRunRequest{
243252
ObservableServiceId: cfg.Service.ID,
244253
CliVersion: version.Version,
245254
IsValidationRun: true,
246-
CommitSha: stringPtr(getCommitSHAFromEnv()),
255+
CommitSha: stringPtr(commitSha),
247256
BranchName: stringPtr(getBranchFromEnv()),
248257
}
249258
} else {
@@ -314,6 +323,39 @@ func runTests(cmd *cobra.Command, args []string) error {
314323

315324
executor.SetEnableServiceLogs(enableServiceLogs || debug)
316325

326+
// Coverage activation:
327+
// - Config-driven: coverage.enabled=true in config activates during validation runs (silent, for upload)
328+
// - Flag-driven: --show-coverage or --coverage-output activates anytime (for local dev/debugging)
329+
coverageFromConfig := getConfigErr == nil && cfg.Coverage.Enabled && isValidation
330+
coverageFromFlags := showCoverage || coverageOutputPath != ""
331+
coverageEnabled := coverageFromConfig || coverageFromFlags
332+
if coverageEnabled {
333+
executor.SetCoverageEnabled(true)
334+
executor.SetShowCoverage(showCoverage)
335+
if coverageOutputPath != "" {
336+
executor.SetCoverageOutputPath(coverageOutputPath)
337+
}
338+
if getConfigErr == nil {
339+
if len(cfg.Coverage.Include) > 0 {
340+
executor.SetCoverageIncludePatterns(cfg.Coverage.Include)
341+
}
342+
if len(cfg.Coverage.Exclude) > 0 {
343+
executor.SetCoverageExcludePatterns(cfg.Coverage.Exclude)
344+
}
345+
if cfg.Coverage.StripPathPrefix != "" {
346+
executor.SetCoverageStripPrefix(cfg.Coverage.StripPathPrefix)
347+
}
348+
}
349+
// Coverage requires serial execution (concurrency=1) because per-test
350+
// snapshots rely on the SDK resetting counters between tests.
351+
executor.SetConcurrency(1)
352+
if showCoverage {
353+
log.Stderrln("➤ Coverage collection enabled (concurrency forced to 1)")
354+
} else {
355+
log.Debug("Coverage collection enabled via config (concurrency forced to 1)")
356+
}
357+
}
358+
317359
// Initialize results saving (--save-results json|agent)
318360
var agentWriter *runner.AgentWriter
319361
var saveResultsDir string
@@ -454,6 +496,51 @@ func runTests(cmd *cobra.Command, args []string) error {
454496
})
455497
}
456498

499+
// Coverage: wrap the OnTestCompleted callback to take snapshots between tests.
500+
// Snapshot runs BEFORE the existing callback (which uploads results) so that
501+
// per-test coverage data is available when building the upload proto.
502+
if coverageEnabled {
503+
existingCallback := executor.OnTestCompleted
504+
executor.SetOnTestCompleted(func(res runner.TestResult, test runner.Test) {
505+
// Take coverage snapshot FIRST so data is available for upload.
506+
// Always continue to existingCallback even on error so test results still upload.
507+
lineCounts, err := executor.TakeCoverageSnapshot()
508+
if err != nil {
509+
log.Warn("Failed to take coverage snapshot", "testID", test.TraceID, "error", err)
510+
}
511+
512+
if err == nil {
513+
executor.AddCoverageRecord(runner.CoverageTestRecord{
514+
TestID: test.TraceID,
515+
TestName: test.DisplayName,
516+
SuiteStatus: test.SuiteStatus,
517+
Coverage: lineCounts,
518+
})
519+
520+
// Store detail for TUI display
521+
detail := runner.SnapshotToCoverageDetail(lineCounts)
522+
executor.SetTestCoverageDetail(test.TraceID, detail)
523+
524+
// Print sub-line in --print mode when --show-coverage is active
525+
if !interactive && showCoverage {
526+
totalLines := 0
527+
for _, fd := range detail {
528+
totalLines += fd.CoveredCount
529+
}
530+
if totalLines > 0 {
531+
log.UserProgress(fmt.Sprintf(" ↳ coverage: %d lines across %d files", totalLines, len(detail)))
532+
}
533+
}
534+
}
535+
536+
// Now run the existing callback (which uploads results).
537+
// Coverage data is available via GetTestCoverageDetail() for the upload.
538+
if existingCallback != nil {
539+
existingCallback(res, test)
540+
}
541+
})
542+
}
543+
457544
var tests []runner.Test
458545
var err error
459546

@@ -781,7 +868,11 @@ func runTests(cmd *cobra.Command, args []string) error {
781868
passed, failed := countPassedFailed(results)
782869
statusMessage = fmt.Sprintf("Validation complete: %d passed, %d failed", passed, failed)
783870
}
784-
if err := runner.ReportDriftRunSuccess(context.Background(), client, driftRunID, authOptions, results, statusMessage); err != nil {
871+
var interactiveCoverageBaseline, interactiveCoverageOriginal runner.CoverageSnapshot
872+
if coverageEnabled && isValidation {
873+
interactiveCoverageBaseline, interactiveCoverageOriginal = executor.GetCoverageBaselineForUpload()
874+
}
875+
if err := runner.ReportDriftRunSuccess(context.Background(), client, driftRunID, authOptions, results, interactiveCoverageBaseline, interactiveCoverageOriginal, commitSha, statusMessage); err != nil {
785876
log.Warn("Interactive: cloud finalize failed", "error", err)
786877
}
787878
mu.Lock()
@@ -896,6 +987,19 @@ func runTests(cmd *cobra.Command, args []string) error {
896987
log.Stderrln(fmt.Sprintf("➤ Running %d tests (concurrency: %d)...\n", len(tests), executor.GetConcurrency()))
897988
}
898989

990+
// Coverage: take baseline with ?baseline=true to capture ALL coverable lines
991+
// (including uncovered at count=0) for the aggregate denominator.
992+
// This also resets counters so the first test gets clean data.
993+
if coverageEnabled {
994+
baseline, err := executor.TakeCoverageBaseline()
995+
if err != nil {
996+
log.Warn("Failed to take baseline coverage snapshot", "error", err)
997+
} else {
998+
executor.SetCoverageBaseline(baseline)
999+
log.Debug("Coverage baseline taken (counters reset, all coverable lines captured)")
1000+
}
1001+
}
1002+
8991003
results, err = executor.RunTests(tests)
9001004
if err != nil {
9011005
cmd.SilenceUsage = true
@@ -946,6 +1050,15 @@ func runTests(cmd *cobra.Command, args []string) error {
9461050
_ = os.Stdout.Sync()
9471051
time.Sleep(1 * time.Millisecond)
9481052

1053+
// Coverage: print summary and write output file
1054+
if coverageEnabled {
1055+
if records := executor.GetCoverageRecords(); len(records) > 0 {
1056+
if err := executor.ProcessCoverage(records); err != nil {
1057+
log.Warn("Failed to process coverage", "error", err)
1058+
}
1059+
}
1060+
}
1061+
9491062
var outputErr error
9501063
if !interactive {
9511064
// Results already streamed, just print summary
@@ -966,7 +1079,12 @@ func runTests(cmd *cobra.Command, args []string) error {
9661079
}
9671080
// streamed is always true here so this only updates the CI status
9681081
// Does NOT upload results to the backend as they are already uploaded via UploadSingleTestResult during the callback
969-
if err := runner.ReportDriftRunSuccess(context.Background(), client, driftRunID, authOptions, results, statusMessage); err != nil {
1082+
// Coverage baseline (if enabled) is piggybacked on this status update
1083+
var headlessCoverageBaseline, headlessCoverageOriginal runner.CoverageSnapshot
1084+
if coverageEnabled && isValidation {
1085+
headlessCoverageBaseline, headlessCoverageOriginal = executor.GetCoverageBaselineForUpload()
1086+
}
1087+
if err := runner.ReportDriftRunSuccess(context.Background(), client, driftRunID, authOptions, results, headlessCoverageBaseline, headlessCoverageOriginal, commitSha, statusMessage); err != nil {
9701088
log.Warn("Headless: cloud finalize failed", "error", err)
9711089
}
9721090
if isValidation {

docs/drift/configuration.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,47 @@ This will not affect CLI behavior. See SDK for more details:
392392
</tbody>
393393
</table>
394394

395+
## Coverage
396+
397+
Configuration for code coverage collection. See [`docs/drift/coverage.md`](coverage.md) for full documentation.
398+
399+
<table>
400+
<thead>
401+
<tr>
402+
<th>Key</th>
403+
<th>Type</th>
404+
<th>Default</th>
405+
<th>Description</th>
406+
</tr>
407+
</thead>
408+
<tbody>
409+
<tr>
410+
<td><code>coverage.enabled</code></td>
411+
<td>bool</td>
412+
<td><code>false</code></td>
413+
<td>When <code>true</code>, automatically collect coverage during suite validation runs on the default branch. No CI changes needed.</td>
414+
</tr>
415+
<tr>
416+
<td><code>coverage.include</code></td>
417+
<td>string[]</td>
418+
<td>(all files)</td>
419+
<td>Only include files matching at least one pattern. Supports <code>**</code> for recursive matching. Paths are git-relative.</td>
420+
</tr>
421+
<tr>
422+
<td><code>coverage.exclude</code></td>
423+
<td>string[]</td>
424+
<td>(none)</td>
425+
<td>Exclude files matching any pattern. Applied after include. Supports <code>**</code> for recursive matching.</td>
426+
</tr>
427+
<tr>
428+
<td><code>coverage.strip_path_prefix</code></td>
429+
<td>string</td>
430+
<td>(none)</td>
431+
<td>Strip this prefix from coverage file paths. Required for Docker Compose — set to the container mount point (e.g., <code>/app</code>).</td>
432+
</tr>
433+
</tbody>
434+
</table>
435+
395436
## Config overrides
396437

397438
### Flags that override config

0 commit comments

Comments
 (0)