feat(cli): add --exit-on-violation and exit 2 when it fires

This commit is contained in:
pj committed 2026-08-13 00:33:58 +05:30
1 parent 4d45c428f8
commit 5791f644ef
3 files changed
+114 -38

No files matched your search

+39 -23
View File
@@ -11,6 +11,8 @@ import (
"os/signal" "os/signal"
"syscall" "syscall"
"time" "time"
"github.com/priyanshujain/sanderling/internal/testrun"
) )
// Version is stamped at build time via goreleaser ldflags. // Version is stamped at build time via goreleaser ldflags.
@@ -18,21 +20,22 @@ import (
var Version = "dev" var Version = "dev"
type testOptions struct { type testOptions struct {
spec string spec string
bundleID string bundleID string
platform string platform string
avd string avd string
device string device string
iosDevice string iosDevice string
iosAppPath string iosAppPath string
androidAppPath string androidAppPath string
duration time.Duration duration time.Duration
maxSteps int maxSteps int
arm string arm string
seed int64 seed int64
output string output string
clearData bool clearData bool
generator string generator string
exitOnViolation bool
} }
const topUsage = `sanderling is a property-based UI fuzzer for mobile apps. const topUsage = `sanderling is a property-based UI fuzzer for mobile apps.
@@ -68,6 +71,7 @@ func parseTestArgs(args []string, stderr io.Writer) (testOptions, error) {
flagSet.BoolVar(&options.clearData, "clear-data", true, "clear app data before launching so each run starts from a fresh install; pass --clear-data=false to resume prior state") flagSet.BoolVar(&options.clearData, "clear-data", true, "clear app data before launching so each run starts from a fresh install; pass --clear-data=false to resume prior state")
flagSet.StringVar(&options.arm, "arm", "", "experiment cell label, recorded in meta.json so a directory of runs can be attributed to a cell") flagSet.StringVar(&options.arm, "arm", "", "experiment cell label, recorded in meta.json so a directory of runs can be attributed to a cell")
flagSet.StringVar(&options.generator, "generator", "seeded", "action generator: seeded (weighted random) or llm (model picks from the same candidate set; requires generator = llm() in the spec)") flagSet.StringVar(&options.generator, "generator", "seeded", "action generator: seeded (weighted random) or llm (model picks from the same candidate set; requires generator = llm() in the spec)")
flagSet.BoolVar(&options.exitOnViolation, "exit-on-violation", false, "stop the run at the first property violation and exit 2, so CI can tell a found bug (2) from a broken harness (1)")
if err := flagSet.Parse(args); err != nil { if err := flagSet.Parse(args); err != nil {
return testOptions{}, err return testOptions{}, err
} }
@@ -141,13 +145,25 @@ func run(args []string, stdout, stderr io.Writer) error {
} }
func main() { func main() {
if err := run(os.Args, os.Stdout, os.Stderr); err != nil { if code := exitCode(run(os.Args, os.Stdout, os.Stderr), os.Stderr); code != 0 {
// flag.ErrHelp means -h/--help was requested; flag already printed os.Exit(code)
// usage to stderr, so exit 0 rather than treating it as a failure.
if errors.Is(err, flag.ErrHelp) {
return
}
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
} }
} }
// exitCode maps a command result to the process status and reports it on
// stderr. 2 means the run did its job and found violations under
// --exit-on-violation; 1 stays "something went wrong", so CI can tell a found
// bug from a broken harness. flag.ErrHelp means -h/--help was requested and
// flag already printed usage, so it exits 0 rather than reading as a failure.
func exitCode(err error, stderr io.Writer) int {
if err == nil || errors.Is(err, flag.ErrHelp) {
return 0
}
var violations testrun.ViolationsError
if errors.As(err, &violations) {
fmt.Fprintf(stderr, "violations: %d\n", violations.Count)
return 2
}
fmt.Fprintf(stderr, "error: %v\n", err)
return 1
}
+59
View File
@@ -2,10 +2,14 @@ package main
import ( import (
"bytes" "bytes"
"errors"
"flag"
"io" "io"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/priyanshujain/sanderling/internal/testrun"
) )
func TestParseTestArgs_Defaults(t *testing.T) { func TestParseTestArgs_Defaults(t *testing.T) {
@@ -311,3 +315,58 @@ func TestParseTestArgs_ArmLabel(t *testing.T) {
t.Errorf("arm: got %q, want seeded-identifier", options.arm) t.Errorf("arm: got %q, want seeded-identifier", options.arm)
} }
} }
func TestParseTestArgs_ExitOnViolationDefaultsOff(t *testing.T) {
options, err := parseTestArgs([]string{
"--spec", "s.ts",
"--bundle-id", "com.example",
}, io.Discard)
if err != nil {
t.Fatal(err)
}
if options.exitOnViolation {
t.Error("exitOnViolation default: got true, want false")
}
}
func TestParseTestArgs_ExitOnViolation(t *testing.T) {
options, err := parseTestArgs([]string{
"--spec", "s.ts",
"--bundle-id", "com.example",
"--exit-on-violation",
}, io.Discard)
if err != nil {
t.Fatal(err)
}
if !options.exitOnViolation {
t.Error("expected exitOnViolation=true")
}
}
// TestExitCode_SeparatesFoundBugsFromBrokenHarnesses pins the three statuses CI
// reads: 0 clean, 2 the run found violations, 1 everything else. A workflow
// that asserts "the known bug is still found" is only meaningful while 2 and 1
// stay distinct.
func TestExitCode_SeparatesFoundBugsFromBrokenHarnesses(t *testing.T) {
for _, testCase := range []struct {
name string
err error
want int
says string
}{
{"clean run", nil, 0, ""},
{"help", flag.ErrHelp, 0, ""},
{"violations found", testrun.ViolationsError{Count: 2}, 2, "violations: 2"},
{"broken harness", errors.New("launch app: no device"), 1, "error: launch app"},
} {
t.Run(testCase.name, func(t *testing.T) {
var stderr bytes.Buffer
if got := exitCode(testCase.err, &stderr); got != testCase.want {
t.Errorf("exit code: got %d, want %d", got, testCase.want)
}
if testCase.says != "" && !strings.Contains(stderr.String(), testCase.says) {
t.Errorf("stderr %q does not mention %q", stderr.String(), testCase.says)
}
})
}
}
+16 -15
View File
@@ -9,20 +9,21 @@ import (
func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer) error { func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer) error {
return testrun.Execute(ctx, testrun.Options{ return testrun.Execute(ctx, testrun.Options{
Spec: options.spec, Spec: options.spec,
BundleID: options.bundleID, BundleID: options.bundleID,
Platform: options.platform, Platform: options.platform,
AVD: options.avd, AVD: options.avd,
Device: options.device, Device: options.device,
IosDevice: options.iosDevice, IosDevice: options.iosDevice,
IosAppPath: options.iosAppPath, IosAppPath: options.iosAppPath,
AndroidAppPath: options.androidAppPath, AndroidAppPath: options.androidAppPath,
Duration: options.duration, Duration: options.duration,
MaxSteps: options.maxSteps, MaxSteps: options.maxSteps,
Seed: options.seed, Seed: options.seed,
Output: options.output, Output: options.output,
ClearData: options.clearData, ClearData: options.clearData,
Generator: options.generator, Generator: options.generator,
Arm: options.arm, Arm: options.arm,
ExitOnViolation: options.exitOnViolation,
}, stdout) }, stdout)
} }