From 8388ac5d2a5ef965443ae620c838e97b9ff7c8d5 Mon Sep 17 00:00:00 2001 From: PJ Date: Wed, 12 Aug 2026 18:11:24 +0530 Subject: [PATCH] feat(cli): add --max-steps for step-bounded runs runner.Options.MaxSteps already worked but was unreachable from the command line. A step budget is what makes two generators comparable: one making a model call per step and one drawing from a PRNG are not comparable per second. Claude-Session: https://claude.ai/code/session_01A5KmftdEJ49A9z5mF5ESrX --- cmd/sanderling/main.go | 5 +++++ cmd/sanderling/main_test.go | 38 +++++++++++++++++++++++++++++++++++++ cmd/sanderling/test_run.go | 1 + internal/testrun/testrun.go | 8 +++++++- 4 files changed, 51 insertions(+), 1 deletion(-) diff --git a/cmd/sanderling/main.go b/cmd/sanderling/main.go index 9ba778e..b57dcd1 100644 --- a/cmd/sanderling/main.go +++ b/cmd/sanderling/main.go @@ -27,6 +27,7 @@ type testOptions struct { iosAppPath string androidAppPath string duration time.Duration + maxSteps int seed int64 output string clearData bool @@ -60,6 +61,7 @@ func parseTestArgs(args []string, stderr io.Writer) (testOptions, error) { flagSet.StringVar(&options.iosAppPath, "ios-app-path", "", "path to the .app bundle for iOS clear-state reinstall (simulator: simctl; device: devicectl)") flagSet.StringVar(&options.androidAppPath, "android-app-path", "", "path to the .apk for Android clear-state reinstall; required to reset apps on OEM builds that deny `pm clear`") flagSet.DurationVar(&options.duration, "duration", 5*time.Minute, "total test duration") + flagSet.IntVar(&options.maxSteps, "max-steps", 0, "stop after this many steps (0 = no cap; the duration deadline governs). A step budget is what makes two generators comparable, since one making a model call per step and one drawing from a PRNG are not comparable per second") flagSet.Int64Var(&options.seed, "seed", 0, "RNG seed (0 = random)") flagSet.StringVar(&options.output, "output", "./runs", "output directory for traces") 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") @@ -78,6 +80,9 @@ func parseTestArgs(args []string, stderr io.Writer) (testOptions, error) { default: return testOptions{}, fmt.Errorf("unsupported platform: %q (android, ios, web)", options.platform) } + if options.maxSteps < 0 { + return testOptions{}, fmt.Errorf("--max-steps must not be negative: %d", options.maxSteps) + } switch options.generator { case "seeded", "llm": default: diff --git a/cmd/sanderling/main_test.go b/cmd/sanderling/main_test.go index 7367551..3e1cb85 100644 --- a/cmd/sanderling/main_test.go +++ b/cmd/sanderling/main_test.go @@ -259,3 +259,41 @@ func TestRun_TestSubcommand_PipelineErrors(t *testing.T) { t.Errorf("expected bundle-resolution error for the missing spec, got %v", err) } } + +func TestParseTestArgs_MaxStepsDefaultsToUncapped(t *testing.T) { + options, err := parseTestArgs([]string{ + "--spec", "s.ts", + "--bundle-id", "com.example", + }, io.Discard) + if err != nil { + t.Fatal(err) + } + if options.maxSteps != 0 { + t.Errorf("maxSteps default: got %d, want 0", options.maxSteps) + } +} + +func TestParseTestArgs_MaxSteps(t *testing.T) { + options, err := parseTestArgs([]string{ + "--spec", "s.ts", + "--bundle-id", "com.example", + "--max-steps", "300", + }, io.Discard) + if err != nil { + t.Fatal(err) + } + if options.maxSteps != 300 { + t.Errorf("maxSteps: got %d, want 300", options.maxSteps) + } +} + +func TestParseTestArgs_RejectsNegativeMaxSteps(t *testing.T) { + _, err := parseTestArgs([]string{ + "--spec", "s.ts", + "--bundle-id", "com.example", + "--max-steps", "-1", + }, io.Discard) + if err == nil { + t.Fatal("expected an error for a negative --max-steps") + } +} diff --git a/cmd/sanderling/test_run.go b/cmd/sanderling/test_run.go index 477392f..8b31ab0 100644 --- a/cmd/sanderling/test_run.go +++ b/cmd/sanderling/test_run.go @@ -18,6 +18,7 @@ func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer) IosAppPath: options.iosAppPath, AndroidAppPath: options.androidAppPath, Duration: options.duration, + MaxSteps: options.maxSteps, Seed: options.seed, Output: options.output, ClearData: options.clearData, diff --git a/internal/testrun/testrun.go b/internal/testrun/testrun.go index b214eb7..c5727e6 100644 --- a/internal/testrun/testrun.go +++ b/internal/testrun/testrun.go @@ -31,6 +31,7 @@ type Options struct { IosAppPath string AndroidAppPath string Duration time.Duration + MaxSteps int Seed int64 Output string ClearData bool @@ -159,9 +160,14 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error { }() fmt.Fprintf(stdout, "trace dir: %s\n", runDirectory) - fmt.Fprintf(stdout, "running for %s (seed=%d)\n", options.Duration, seed) + if options.MaxSteps > 0 { + fmt.Fprintf(stdout, "running for %s or %d steps, whichever comes first (seed=%d)\n", options.Duration, options.MaxSteps, seed) + } else { + fmt.Fprintf(stdout, "running for %s (seed=%d)\n", options.Duration, seed) + } summary, err := runner.Run(ctx, runner.Options{ Duration: options.Duration, + MaxSteps: options.MaxSteps, IdleTimeout: 1 * time.Second, BundleID: options.BundleID, Driver: activeDriver,