fix(testrun): the refusal asks whether the generator drove, not whether anything did

A dead provider against folio exited 0 on a real emulator: the login
setup dispatched three actions before the generator was consulted, so
DispatchedActions was 3 and the gate never fired while the generator
drove the app zero times across 83 steps. Any spec with a login setup
was immune, which is the normal case.

Summary counts generator actions separately and the refusal reads that.
NoActionsDispatchedError becomes NoGeneratorActionsError, because a run
that dispatched three login taps was lying in the old name.
This commit is contained in:
pj committed 2026-08-18 16:29:03 +05:30
1 parent 5a36b583ea
commit 58168a6a3e
6 files changed
+274 -29

No files matched your search

+23 -18
View File
@@ -265,13 +265,16 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error {
// fraction of them: a screen that composes now and then costs a healthy android
// run a step or two, and a check that fired on those would be red on every run.
//
// A run that dispatched no action at all fails on the same grounds, and the
// A run whose generator dispatched no action fails on the same grounds, and the
// threshold is zero for the same reason: a generator with nothing to offer on
// some screens is ordinary, one with nothing to offer on every screen of a whole
// run drove nothing. --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.
// 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.
func runOutcome(options Options, summary runner.Summary) error {
if summary.Steps > 0 && summary.SkippedVerification == summary.Steps {
return VacuousRunError{Steps: summary.Steps}
@@ -279,8 +282,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.DispatchedActions == 0 {
return NoActionsDispatchedError{
if !options.AllowNoProperties && summary.Steps > 0 && summary.GeneratorActions == 0 {
return NoGeneratorActionsError{
Steps: summary.Steps,
SkippedActions: summary.SkippedActions,
}
@@ -352,13 +355,14 @@ func (e NoPropertiesError) Error() string {
e.Spec)
}
// NoActionsDispatchedError reports a run not one of whose steps drove the app.
// Every screen it judged was the one it launched on, so its empty violation list
// says as much about the app as a spec with no properties would: the run
// observed, judged the same state over and over, and exercised nothing. It stays
// untyped to the CLI's violation path like VacuousRunError, so it exits 1 as a
// run that holds no verdict rather than 2.
type NoActionsDispatchedError struct {
// NoGeneratorActionsError reports a run not one of whose steps was driven by the
// action generator. Setup can put the app in position, but only the generator
// explores it, so every screen this run judged was one setup left it on and its
// empty violation list says as much about the app as a spec with no properties
// would: the run observed, judged the same state over and over, and exercised
// nothing. It stays untyped to the CLI's violation path like VacuousRunError, so
// it exits 1 as a run that holds no verdict rather than 2.
type NoGeneratorActionsError struct {
Steps int
// SkippedActions is the runner's per-reason count of actions that never
// reached the app, which is where the cause is: a picker with no candidate
@@ -366,11 +370,12 @@ type NoActionsDispatchedError struct {
SkippedActions map[string]int
}
func (e NoActionsDispatchedError) Error() string {
func (e NoGeneratorActionsError) Error() string {
return fmt.Sprintf(
"%d step(s) ran and none of them dispatched an action: the run observed the app "+
"and never drove it, so it judged its launch screen over and over and its "+
"violation count says nothing about the rest of the app%s",
"%d step(s) ran and the action generator drove the app in none of them: whatever "+
"the spec's setup did to get the app into position, nothing explored it from "+
"there, so the run judged one screen over and over and its violation count "+
"says nothing about the rest of the app%s",
e.Steps, skipReasonSuffix(e.SkippedActions))
}
+50 -8
View File
@@ -299,9 +299,10 @@ func TestRunOutcome_ReportsViolationsOnlyUnderTheFlag(t *testing.T) {
violated := runner.Summary{
Steps: 7,
DispatchedActions: 7,
GeneratorActions: 7,
Violations: []runner.ViolationRecord{{StepIndex: 3, Properties: []string{"balanceMoves"}}},
}
clean := runner.Summary{Steps: 7, DispatchedActions: 7}
clean := runner.Summary{Steps: 7, DispatchedActions: 7, GeneratorActions: 7}
if err := runOutcome(Options{}, violated); err != nil {
t.Errorf("without --exit-on-violation a violated run must succeed, got %v", err)
@@ -339,7 +340,12 @@ func TestRunOutcome_ARunThatJudgedNothingIsNotASuccess(t *testing.T) {
// A screen that composes now and then costs a run steps, not its verdict. A
// check that fired here would turn every healthy android run red.
mostlyJudged := runner.Summary{Steps: 6, SkippedVerification: 5, DispatchedActions: 1}
mostlyJudged := runner.Summary{
Steps: 6,
SkippedVerification: 5,
DispatchedActions: 1,
GeneratorActions: 1,
}
if err := runOutcome(Options{}, mostlyJudged); err != nil {
t.Errorf("a run that judged one of its 6 steps must succeed, got %v", err)
}
@@ -355,10 +361,10 @@ func TestRunOutcome_ARunThatDroveNothingIsNotASuccess(t *testing.T) {
SkippedActions: map[string]int{"no_action_produced": 200},
}
err := runOutcome(Options{}, droveNothing)
var dead NoActionsDispatchedError
var dead NoGeneratorActionsError
if !errors.As(err, &dead) {
t.Fatalf("a run that dispatched none of its 200 steps' actions came back %v, "+
"want a NoActionsDispatchedError", err)
"want a NoGeneratorActionsError", err)
}
if dead.Steps != 200 {
t.Errorf("steps: got %d, want 200", dead.Steps)
@@ -367,11 +373,12 @@ func TestRunOutcome_ARunThatDroveNothingIsNotASuccess(t *testing.T) {
t.Errorf("the error never names why nothing was dispatched: %v", dead)
}
// One action is exploration, however little. A check that fired here would
// be red on any run whose screen offers the generator nothing for a while.
droveOnce := runner.Summary{Steps: 200, DispatchedActions: 1}
// One generator action is exploration, however little. A check that fired
// here would be red on any run whose screen offers the generator nothing for
// a while.
droveOnce := runner.Summary{Steps: 200, DispatchedActions: 1, GeneratorActions: 1}
if err := runOutcome(Options{}, droveOnce); err != nil {
t.Errorf("a run that dispatched one action must succeed, got %v", err)
t.Errorf("a run whose generator dispatched one action must succeed, got %v", err)
}
// The sweeps that measure what a spec extracts and where a generator reaches
@@ -401,6 +408,41 @@ func TestRunOutcome_ARunThatDroveNothingIsNotASuccess(t *testing.T) {
}
}
// A spec whose setup logs in drives the app before the generator is ever
// consulted, so a run whose every model call failed still reached the driver a
// few times. Those actions are the harness getting into position: counting them
// as the run driving the app passed 86 steps of folio that explored nothing.
func TestRunOutcome_SetupActionsDoNotCarryARunWhoseGeneratorDroveNothing(t *testing.T) {
loginThenNothing := runner.Summary{
Steps: 86,
DispatchedActions: 3,
GeneratorActions: 0,
SkippedActions: map[string]int{"no_action_produced": 83},
}
err := runOutcome(Options{}, loginThenNothing)
var dead NoGeneratorActionsError
if !errors.As(err, &dead) {
t.Fatalf("a run whose 3 dispatched actions all came from setup came back %v, "+
"want a NoGeneratorActionsError", err)
}
if dead.Steps != 86 {
t.Errorf("steps: got %d, want 86", dead.Steps)
}
if !strings.Contains(dead.Error(), "no_action_produced") {
t.Errorf("the error never names why the generator dispatched nothing: %v", dead)
}
// The same run under --exit-on-violation still reports its evidence: CI
// reads exit 2 as "the run found the bug", and a setup-only run that found
// one must not be downgraded to a broken harness.
found := loginThenNothing
found.Violations = []runner.ViolationRecord{{StepIndex: 2, Properties: []string{"balanceMoves"}}}
var violations ViolationsError
if err := runOutcome(Options{ExitOnViolation: true}, found); !errors.As(err, &violations) {
t.Errorf("a setup-only run that found a violation came back %v, want a ViolationsError", err)
}
}
// wedgedLaunchDriver never returns from Launch, standing in for a driver whose
// device-side session is stuck.
type wedgedLaunchDriver struct {