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 {
0 commit comments