Merge origin/master into llm-recording-and-analysis

Brings in #73, which landed web fact-parity work overlapping this branch:
selector-tagged ax handles, aria-disabled in `enabled`, shadow-DOM traversal
and a `scrollable`/`editable` dump, plus a third folio property and two new
testTags on HomeScreen.

Six files conflicted. The option-carrying ones took the union of both sides'
fields, so `--label-source` and `--exit-on-violation` both reach the pipeline.
In web-runtime.ts both sides changed how an element is described: master gave
`elementHandle` a selector to tag handles with, this branch gave it raw
attribute names and a field hint. Both survive, and `enabled` now answers
through master's isEnabled while `editable` stays.

Claude-Session: https://claude.ai/code/session_01A5KmftdEJ49A9z5mF5ESrX
This commit is contained in:
pj committed 2026-08-15 13:28:11 +05:30
commit d987526e47
73 files changed
+6639 -457

No files matched your search

+40 -24
View File
@@ -11,6 +11,8 @@ import (
"os/signal"
"syscall"
"time"
"github.com/priyanshujain/sanderling/internal/testrun"
)
// Version is stamped at build time via goreleaser ldflags.
@@ -18,22 +20,23 @@ import (
var Version = "dev"
type testOptions struct {
spec string
bundleID string
platform string
avd string
device string
iosDevice string
iosAppPath string
androidAppPath string
duration time.Duration
maxSteps int
arm string
seed int64
output string
clearData bool
generator string
labelSource string
spec string
bundleID string
platform string
avd string
device string
iosDevice string
iosAppPath string
androidAppPath string
duration time.Duration
maxSteps int
arm string
seed int64
output string
clearData bool
generator string
labelSource string
exitOnViolation bool
}
const topUsage = `sanderling is a property-based UI fuzzer for mobile apps.
@@ -70,6 +73,7 @@ func parseTestArgs(args []string, stderr io.Writer) (testOptions, error) {
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.labelSource, "label-source", "visible-text", "how candidates are named to the llm generator: visible-text (what a user reads) or resource-id (the identifier the app assigned). The seeded generator picks by index and ignores this")
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 {
return testOptions{}, err
}
@@ -151,13 +155,25 @@ func run(args []string, stdout, stderr io.Writer) error {
}
func main() {
if err := run(os.Args, os.Stdout, os.Stderr); err != nil {
// flag.ErrHelp means -h/--help was requested; flag already printed
// 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)
if code := exitCode(run(os.Args, os.Stdout, os.Stderr), os.Stderr); code != 0 {
os.Exit(code)
}
}
// 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
}
+58
View File
@@ -2,11 +2,14 @@ package main
import (
"bytes"
"errors"
"flag"
"io"
"strings"
"testing"
"time"
"github.com/priyanshujain/sanderling/internal/testrun"
"github.com/priyanshujain/sanderling/internal/verifier"
)
@@ -370,3 +373,58 @@ func TestParseTestArgs_ArmLabel(t *testing.T) {
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)
}
})
}
}
+17 -16
View File
@@ -16,21 +16,22 @@ func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer)
// and records another, which is worth being able to test on its own.
func pipelineOptions(options testOptions) testrun.Options {
return testrun.Options{
Spec: options.spec,
BundleID: options.bundleID,
Platform: options.platform,
AVD: options.avd,
Device: options.device,
IosDevice: options.iosDevice,
IosAppPath: options.iosAppPath,
AndroidAppPath: options.androidAppPath,
Duration: options.duration,
MaxSteps: options.maxSteps,
Seed: options.seed,
Output: options.output,
ClearData: options.clearData,
Generator: options.generator,
LabelSource: options.labelSource,
Arm: options.arm,
Spec: options.spec,
BundleID: options.bundleID,
Platform: options.platform,
AVD: options.avd,
Device: options.device,
IosDevice: options.iosDevice,
IosAppPath: options.iosAppPath,
AndroidAppPath: options.androidAppPath,
Duration: options.duration,
MaxSteps: options.maxSteps,
Seed: options.seed,
Output: options.output,
ClearData: options.clearData,
Generator: options.generator,
LabelSource: options.labelSource,
Arm: options.arm,
ExitOnViolation: options.exitOnViolation,
}
}