From aa49c3bc34108bf792e754b057f103de6948dcc6 Mon Sep 17 00:00:00 2001 From: PJ Date: Tue, 18 Aug 2026 20:08:03 +0530 Subject: [PATCH] fix(testrun): a recorded violation outranks the dead-run refusal A campaign never passes --exit-on-violation, so the refusal was discarding runs that had found something: exit_code 1 in the record and the analysis drops them as missing data. A run that recorded a violation holds a verdict, which is the whole reason the refusal exists. --- internal/testrun/testrun.go | 13 ++++++++----- internal/testrun/testrun_test.go | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/internal/testrun/testrun.go b/internal/testrun/testrun.go index 57ad6c6..bd5cf5f 100644 --- a/internal/testrun/testrun.go +++ b/internal/testrun/testrun.go @@ -280,10 +280,12 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error { // run drove nothing. The count is the generator's alone because a spec's setup // drives the app before the generator is consulted, so a login that ran leaves // dispatched actions behind whatever the generator then did. -// --exit-on-violation keeps precedence over it so a run that found something -// still exits on its evidence, and the property-free opt-out exempts the sweeps, -// whose measurement is where a generator reaches and for which "nowhere on this -// build" is a result rather than a broken run. +// +// A recorded violation carries such a run through whether or not +// --exit-on-violation was passed: the refusal exists because a run with no +// verdict must not read as a clean one, and a run that recorded a violation +// holds a verdict. Campaigns pass no flags, so refusing them there would write +// exit_code 1 and lose a real detection to the analysis as missing data. func runOutcome(options Options, summary runner.Summary) error { if summary.Steps > 0 && summary.SkippedVerification == summary.Steps { return VacuousRunError{Steps: summary.Steps} @@ -291,7 +293,8 @@ func runOutcome(options Options, summary runner.Summary) error { if options.ExitOnViolation && len(summary.Violations) > 0 { return ViolationsError{Count: len(summary.Violations)} } - if !options.AllowNoProperties && summary.Steps > 0 && summary.GeneratorActions == 0 { + if !options.AllowNoProperties && len(summary.Violations) == 0 && + summary.Steps > 0 && summary.GeneratorActions == 0 { return NoGeneratorActionsError{ Steps: summary.Steps, SkippedActions: summary.SkippedActions, diff --git a/internal/testrun/testrun_test.go b/internal/testrun/testrun_test.go index 6aa125c..f8cda07 100644 --- a/internal/testrun/testrun_test.go +++ b/internal/testrun/testrun_test.go @@ -473,6 +473,27 @@ func TestRunOutcome_SetupActionsDoNotCarryARunWhoseGeneratorDroveNothing(t *test } } +// A recorded violation is a verdict, and a run that reached one is not a dead +// run whatever drove the app to it. Campaigns are where this bites: they never +// pass --exit-on-violation, so refusing such a run writes exit_code 1 into the +// record and the analysis drops a real detection as missing data. +func TestRunOutcome_ARecordedViolationCarriesARunWhoseGeneratorDroveNothing(t *testing.T) { + setupReachedTheBug := runner.Summary{ + Steps: 40, + DispatchedActions: 3, + GeneratorActions: 0, + SkippedActions: map[string]int{"no_action_produced": 40}, + Violations: []runner.ViolationRecord{ + {StepIndex: 3, Properties: []string{"noUncaughtExceptions"}}, + }, + } + + if err := runOutcome(Options{}, setupReachedTheBug); err != nil { + t.Fatalf("a run that recorded a violation came back %v, want the run to succeed "+ + "so the campaign records exit_code 0 and the detection survives", err) + } +} + // wedgedLaunchDriver never returns from Launch, standing in for a driver whose // device-side session is stuck. type wedgedLaunchDriver struct {