test(runner): a relaunch must not convict the submit counting property

This commit is contained in:
pj committed 2026-08-15 20:38:17 +05:30
1 parent 11f72a722a
commit 46f2969350
2 files changed
+143 -2

No files matched your search

@@ -0,0 +1,141 @@
package runner
import (
"context"
"fmt"
"path/filepath"
"sync/atomic"
"testing"
"time"
"github.com/priyanshujain/sanderling/internal/driver"
mockdriver "github.com/priyanshujain/sanderling/internal/driver/mock"
)
const relaunchBundleID = "app.folio"
// leavesForegroundAfterSubmitDriver is the device condition the app-scope guard
// exists for: the submit tap lands and commits, and the app is no longer the
// foreground app by the time the next step looks. Folio's transactions are in
// sqlite, so the commit survives the relaunch and the next hierarchy read shows
// it.
type leavesForegroundAfterSubmitDriver struct {
*mockdriver.Driver
commitsPerTap int64
committed atomic.Int64
away atomic.Bool
}
func (d *leavesForegroundAfterSubmitDriver) Tap(context.Context, int, int) error {
return d.commitThenLeave()
}
func (d *leavesForegroundAfterSubmitDriver) TapSelector(context.Context, string) error {
return d.commitThenLeave()
}
func (d *leavesForegroundAfterSubmitDriver) commitThenLeave() error {
d.committed.Add(d.commitsPerTap)
d.away.Store(true)
return nil
}
func (d *leavesForegroundAfterSubmitDriver) Launch(
ctx context.Context,
bundleID string,
clearState bool,
env map[string]string,
) error {
d.away.Store(false)
return d.Driver.Launch(ctx, bundleID, clearState, env)
}
func (d *leavesForegroundAfterSubmitDriver) ForegroundApp(context.Context) (string, error) {
if d.away.Load() {
return "com.android.launcher", nil
}
return relaunchBundleID, nil
}
func (d *leavesForegroundAfterSubmitDriver) FocusedWindowApp(ctx context.Context) (string, error) {
return d.ForegroundApp(ctx)
}
func (d *leavesForegroundAfterSubmitDriver) Snapshot(context.Context) (string, driver.Image, error) {
return fmt.Sprintf(homeWithTxnCount, d.committed.Load()), driver.Image{}, nil
}
// A relaunch is not proof that nothing ran before it. The submit was dispatched
// and confirmed; what the relaunch changed is that the app restarted between
// the two readings the property compares. Reporting "no action" for it hands
// submitCommitsOneTransactionPerAction a transaction rise of one against a
// window of zero submits, which is the conviction #77 fixed for the apply-error
// path, manufactured here out of the scope guard instead.
func TestRunner_ARelaunchDoesNotConvictTheSubmitCountingProperty(t *testing.T) {
predicates, err := filepath.Abs("../../examples/folio/sanderling/predicates.ts")
if err != nil {
t.Fatal(err)
}
spec := fmt.Sprintf(submitCountingSpecTemplate, predicates)
run := func(t *testing.T, commitsPerTap int64) []ViolationRecord {
t.Helper()
state := newHarnessWithSpec(t, spec)
device := &leavesForegroundAfterSubmitDriver{Driver: state.mock, commitsPerTap: commitsPerTap}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
Duration: time.Hour,
IdleTimeout: 20 * time.Millisecond,
MaxSteps: 2,
BundleID: relaunchBundleID,
Driver: device,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if summary.Steps != 2 {
t.Fatalf("steps = %d, want 2; the run never reached the step that judges the pair", summary.Steps)
}
if got := device.committed.Load(); got != commitsPerTap*2 {
t.Fatalf("the device committed %d transaction(s), want %d; the taps never reached it",
got, commitsPerTap*2)
}
relaunches := 0
for _, action := range state.mock.Actions() {
if action.Kind == mockdriver.ActionLaunch {
relaunches++
}
}
if relaunches == 0 {
t.Fatal("the app was never relaunched, so the guard this test is about never ran")
}
return summary.Violations
}
t.Run("one transaction per tap is not a double submit", func(t *testing.T) {
if violations := run(t, 1); len(violations) != 0 {
t.Errorf("the counting property convicted a healthy app: %v\n"+
"one transaction rose against a submit the runner confirmed, and the "+
"spec was told no action happened because the app was relaunched",
violations)
}
})
// The control. Without it a green above proves nothing: a property that
// never sees a comparable pair is silently vacuous and reports the same
// empty violation list.
t.Run("two transactions per tap still convicts", func(t *testing.T) {
violations := run(t, 2)
if len(violations) == 0 {
t.Fatal("the counting property missed a double submit; the harness never " +
"put the property in a position to fire, so the case above proves nothing")
}
if violations[0].Properties[0] != "submitCommitsOneTransactionPerAction" {
t.Errorf("violated %v, want submitCommitsOneTransactionPerAction", violations[0].Properties)
}
})
}
@@ -22,7 +22,7 @@ import (
//
// The spec below is the real folio predicate pair, imported from the example,
// so what this asserts is the verdict the shipped property reaches.
const uncertainApplySpecTemplate = `
const submitCountingSpecTemplate = `
import { actions, always, extract, next, Tap } from "@sanderling/spec";
import {
committedTransactionsExceedSubmits,
@@ -95,7 +95,7 @@ func TestRunner_ApplyErrorAfterDispatchDoesNotConvictTheSubmitCountingProperty(t
if err != nil {
t.Fatal(err)
}
spec := fmt.Sprintf(uncertainApplySpecTemplate, predicates)
spec := fmt.Sprintf(submitCountingSpecTemplate, predicates)
run := func(t *testing.T, commitsPerTap int64) []ViolationRecord {
t.Helper()