diff --git a/internal/verifier/bindings.go b/internal/verifier/bindings.go index 4c63a87..0f678dc 100644 --- a/internal/verifier/bindings.go +++ b/internal/verifier/bindings.go @@ -2,6 +2,8 @@ package verifier import ( "fmt" + "log/slog" + "math/big" "time" "github.com/dop251/goja" @@ -57,20 +59,7 @@ type formulaSpec struct { const ( tagFormula = "__sanderlingFormula" tagFormulaSpecIndex = "__sanderlingFormulaSpec" - tagActionGenerator = "__sanderlingActionGenerator" - tagInternalKind = "__sanderlingKind" tagSelector = "__sanderlingSelector" - - internalKindActions = "actions" - internalKindWeighted = "weighted" - internalKindBuiltinTaps = "taps" - internalKindBuiltinDoubleTaps = "doubleTaps" - internalKindBuiltinTyping = "typing" - internalKindBuiltinSwipes = "swipes" - internalKindBuiltinWaitOnce = "waitOnce" - internalKindBuiltinPressKey = "pressKey" - internalKindBuiltinLongPresses = "longPresses" - internalKindBuiltinScrolls = "scrolls" ) // installRuntimeBindings exposes globalThis.__sanderling__ to the loaded spec. @@ -92,65 +81,64 @@ func (v *Verifier) installRuntimeBindings() error { if err := sanderling.Set("eventually", v.bindEventually); err != nil { return err } - if err := sanderling.Set("actions", v.bindActions); err != nil { - return err - } - if err := sanderling.Set("weighted", v.bindWeighted); err != nil { - return err - } - if err := sanderling.Set("from", v.bindFrom); err != nil { - return err - } - if err := sanderling.Set("tap", v.bindTap); err != nil { - return err - } - if err := sanderling.Set("doubleTap", v.bindDoubleTap); err != nil { - return err - } - if err := sanderling.Set("longPress", v.bindLongPress); err != nil { - return err - } - if err := sanderling.Set("scroll", v.bindScroll); err != nil { - return err - } - if err := sanderling.Set("inputText", v.bindInputText); err != nil { - return err - } - if err := sanderling.Set("swipe", v.bindSwipe); err != nil { - return err - } - if err := sanderling.Set("pressKey", v.bindPressKey); err != nil { - return err - } - if err := sanderling.Set("wait", v.bindWait); err != nil { - return err - } - if err := sanderling.Set("taps", v.builtinGenerator(internalKindBuiltinTaps)); err != nil { - return err - } - if err := sanderling.Set("doubleTaps", v.builtinGenerator(internalKindBuiltinDoubleTaps)); err != nil { - return err - } - if err := sanderling.Set("typing", v.builtinGenerator(internalKindBuiltinTyping)); err != nil { - return err - } - if err := sanderling.Set("swipes", v.builtinGenerator(internalKindBuiltinSwipes)); err != nil { - return err - } - if err := sanderling.Set("waitOnce", v.builtinGenerator(internalKindBuiltinWaitOnce)); err != nil { - return err - } - if err := sanderling.Set("pressKeys", v.builtinGenerator(internalKindBuiltinPressKey)); err != nil { - return err - } - if err := sanderling.Set("longPresses", v.builtinGenerator(internalKindBuiltinLongPresses)); err != nil { - return err - } - if err := sanderling.Set("scrolls", v.builtinGenerator(internalKindBuiltinScrolls)); err != nil { - return err - } - return v.runtime.GlobalObject().Set("__sanderling__", sanderling) + if err := v.runtime.GlobalObject().Set("__sanderling__", sanderling); err != nil { + return err + } + return v.installHost() +} + +// installHost exposes globalThis.__sanderlingHost__ for the goja runtime entry. +// The shared picker (pick.ts) draws against it: platform() drives the verb +// matrix and press-key pool; seedHi/seedLo construct its Pcg; queryCandidates +// enumerates targets over the hierarchy tree; reportUnsupported logs once. +func (v *Verifier) installHost() error { + host := v.runtime.NewObject() + if err := host.Set("platform", func(goja.FunctionCall) goja.Value { + return v.runtime.ToValue(v.platform) + }); err != nil { + return err + } + if err := host.Set("seedHi", func(goja.FunctionCall) goja.Value { + return v.runtime.ToValue(new(big.Int).SetUint64(v.seed)) + }); err != nil { + return err + } + if err := host.Set("seedLo", func(goja.FunctionCall) goja.Value { + return v.runtime.ToValue(big.NewInt(0)) + }); err != nil { + return err + } + if err := host.Set("queryCandidates", v.bindQueryCandidates); err != nil { + return err + } + if err := host.Set("reportUnsupported", func(call goja.FunctionCall) goja.Value { + slog.Warn("verb unsupported on platform", "verb", call.Argument(0).String(), "platform", v.platform) + return goja.Undefined() + }); err != nil { + return err + } + return v.runtime.GlobalObject().Set("__sanderlingHost__", host) +} + +// bindQueryCandidates returns the host-enumerated targets for a verb as an +// array of {x, y, selector, width, height}, in tree order. +func (v *Verifier) bindQueryCandidates(call goja.FunctionCall) goja.Value { + verb := call.Argument(0).String() + candidates := v.candidatesForVerb(verb) + array := v.runtime.NewArray() + for index, candidate := range candidates { + item := v.runtime.NewObject() + _ = item.Set("x", candidate.x) + _ = item.Set("y", candidate.y) + _ = item.Set("width", candidate.width) + _ = item.Set("height", candidate.height) + if candidate.selector != "" { + _ = item.Set("selector", candidate.selector) + } + _ = array.Set(fmt.Sprintf("%d", index), item) + } + return array } func (v *Verifier) bindExtract(call goja.FunctionCall) goja.Value { @@ -352,163 +340,3 @@ func (v *Verifier) extractSpecIndex(value goja.Value) (int, bool) { return int(indexValue.ToInteger()), true } -func (v *Verifier) bindActions(call goja.FunctionCall) goja.Value { - if len(call.Arguments) != 1 { - panic(v.runtime.NewTypeError("actions requires a single generator argument")) - } - if _, ok := goja.AssertFunction(call.Arguments[0]); !ok { - panic(v.runtime.NewTypeError("actions argument must be a function")) - } - handle := v.runtime.NewObject() - _ = handle.Set(tagActionGenerator, true) - _ = handle.Set(tagInternalKind, internalKindActions) - _ = handle.Set("generate", call.Arguments[0]) - return handle -} - -func (v *Verifier) bindWeighted(call goja.FunctionCall) goja.Value { - entries := v.runtime.NewArray() - for index, argument := range call.Arguments { - object := argument.ToObject(v.runtime) - if object == nil { - panic(v.runtime.NewTypeError(fmt.Sprintf("weighted entry %d must be a [number, generator] tuple", index))) - } - if err := entries.Set(fmt.Sprintf("%d", index), object); err != nil { - panic(v.runtime.NewGoError(err)) - } - } - handle := v.runtime.NewObject() - _ = handle.Set(tagActionGenerator, true) - _ = handle.Set(tagInternalKind, internalKindWeighted) - _ = handle.Set("entries", entries) - return handle -} - -// bindFrom returns a `{ generate }` that picks uniformly at random from the -// provided items using the verifier's seeded rng. -func (v *Verifier) bindFrom(call goja.FunctionCall) goja.Value { - if len(call.Arguments) != 1 { - panic(v.runtime.NewTypeError("from requires an array argument")) - } - itemsValue := call.Arguments[0] - itemsObject := itemsValue.ToObject(v.runtime) - if itemsObject == nil { - panic(v.runtime.NewTypeError("from argument must be an array")) - } - lengthValue := itemsObject.Get("length") - if lengthValue == nil { - panic(v.runtime.NewTypeError("from argument must be array-like")) - } - length := int(lengthValue.ToInteger()) - - handle := v.runtime.NewObject() - _ = handle.Set("generate", func(goja.FunctionCall) goja.Value { - if length == 0 { - return goja.Undefined() - } - index := v.rng.IntN(length) - return itemsObject.Get(fmt.Sprintf("%d", index)) - }) - return handle -} - -func (v *Verifier) bindTap(call goja.FunctionCall) goja.Value { - parameters := call.Argument(0).ToObject(v.runtime) - if parameters == nil { - panic(v.runtime.NewTypeError("Tap requires {on}")) - } - handle := v.runtime.NewObject() - _ = handle.Set("kind", "Tap") - _ = handle.Set("on", parameters.Get("on")) - return handle -} - -func (v *Verifier) bindDoubleTap(call goja.FunctionCall) goja.Value { - parameters := call.Argument(0).ToObject(v.runtime) - if parameters == nil { - panic(v.runtime.NewTypeError("DoubleTap requires {on}")) - } - handle := v.runtime.NewObject() - _ = handle.Set("kind", "DoubleTap") - _ = handle.Set("on", parameters.Get("on")) - return handle -} - -func (v *Verifier) bindLongPress(call goja.FunctionCall) goja.Value { - parameters := call.Argument(0).ToObject(v.runtime) - if parameters == nil { - panic(v.runtime.NewTypeError("LongPress requires {on}")) - } - handle := v.runtime.NewObject() - _ = handle.Set("kind", "LongPress") - _ = handle.Set("on", parameters.Get("on")) - return handle -} - -func (v *Verifier) bindScroll(call goja.FunctionCall) goja.Value { - parameters := call.Argument(0).ToObject(v.runtime) - if parameters == nil { - panic(v.runtime.NewTypeError("Scroll requires {direction}")) - } - handle := v.runtime.NewObject() - _ = handle.Set("kind", "Scroll") - _ = handle.Set("direction", parameters.Get("direction")) - _ = handle.Set("in", parameters.Get("in")) - return handle -} - -func (v *Verifier) bindInputText(call goja.FunctionCall) goja.Value { - parameters := call.Argument(0).ToObject(v.runtime) - if parameters == nil { - panic(v.runtime.NewTypeError("InputText requires {into, text}")) - } - handle := v.runtime.NewObject() - _ = handle.Set("kind", "InputText") - _ = handle.Set("into", parameters.Get("into")) - _ = handle.Set("text", parameters.Get("text")) - return handle -} - -func (v *Verifier) bindSwipe(call goja.FunctionCall) goja.Value { - parameters := call.Argument(0).ToObject(v.runtime) - if parameters == nil { - panic(v.runtime.NewTypeError("Swipe requires {from, to}")) - } - handle := v.runtime.NewObject() - _ = handle.Set("kind", "Swipe") - _ = handle.Set("from", parameters.Get("from")) - _ = handle.Set("to", parameters.Get("to")) - if duration := parameters.Get("durationMillis"); duration != nil && !goja.IsUndefined(duration) { - _ = handle.Set("durationMillis", duration) - } - return handle -} - -func (v *Verifier) bindPressKey(call goja.FunctionCall) goja.Value { - parameters := call.Argument(0).ToObject(v.runtime) - if parameters == nil { - panic(v.runtime.NewTypeError("PressKey requires {key}")) - } - handle := v.runtime.NewObject() - _ = handle.Set("kind", "PressKey") - _ = handle.Set("key", parameters.Get("key")) - return handle -} - -func (v *Verifier) bindWait(call goja.FunctionCall) goja.Value { - parameters := call.Argument(0).ToObject(v.runtime) - if parameters == nil { - panic(v.runtime.NewTypeError("Wait requires {durationMillis}")) - } - handle := v.runtime.NewObject() - _ = handle.Set("kind", "Wait") - _ = handle.Set("durationMillis", parameters.Get("durationMillis")) - return handle -} - -func (v *Verifier) builtinGenerator(kind string) *goja.Object { - handle := v.runtime.NewObject() - _ = handle.Set(tagActionGenerator, true) - _ = handle.Set(tagInternalKind, kind) - return handle -} diff --git a/internal/verifier/worker.go b/internal/verifier/worker.go index 2252437..e5e8fcc 100644 --- a/internal/verifier/worker.go +++ b/internal/verifier/worker.go @@ -6,9 +6,7 @@ import ( "errors" "fmt" "maps" - "math/rand/v2" "sort" - "strings" "time" "github.com/dop251/goja" @@ -23,9 +21,11 @@ type Verifier struct { formulas []*formulaState formulaSpecs []formulaSpec - properties map[string]int // property name -> formula-spec index - actionGenerator goja.Value - setupGenerator goja.Value + properties map[string]int // property name -> formula-spec index + + // nextActionFn is the bundle-installed __sanderlingNextAction__, which runs + // the shared picker (pick.ts) over the shared Pcg. + nextActionFn goja.Callable evaluators map[string]*ltl.Evaluator @@ -41,14 +41,24 @@ type Verifier struct { runStart time.Time appPackage string - - rng *rand.Rand + platform string + seed uint64 } type Option func(*Verifier) -func WithRand(rng *rand.Rand) Option { - return func(v *Verifier) { v.rng = rng } +// WithSeed sets the 64-bit seed the JS picker constructs its Pcg from +// (new Pcg(seed, 0), matching the web bundle's SANDERLING_SEED). The verifier +// exposes it to the bundle via the __sanderlingHost__.seedHi/seedLo binds. +func WithSeed(seed uint64) Option { + return func(v *Verifier) { v.seed = seed } +} + +// WithPlatform names the platform the host reports to the picker +// ("android"/"ios"/"web"); it drives the verb-support matrix and the press-key +// pool. Empty defaults to "android". +func WithPlatform(platform string) Option { + return func(v *Verifier) { v.platform = platform } } // WithAppPackage scopes random-action target selection to the app under test. @@ -66,11 +76,14 @@ func New(options ...Option) (*Verifier, error) { evaluators: map[string]*ltl.Evaluator{}, priorVerdicts: map[string]ltl.Verdict{}, witnesses: map[string]Witness{}, - rng: rand.New(rand.NewPCG(0, 0)), + platform: "android", } for _, option := range options { option(verifier) } + if verifier.platform == "" { + verifier.platform = "android" + } if err := verifier.installRuntimeBindings(); err != nil { return nil, fmt.Errorf("install bindings: %w", err) } @@ -107,12 +120,14 @@ func (v *Verifier) Load(source string) error { } } - if actionsValue := v.runtime.GlobalObject().Get("actions"); actionsValue != nil && !goja.IsUndefined(actionsValue) && !goja.IsNull(actionsValue) { - v.actionGenerator = actionsValue - } - - if setupValue := v.runtime.GlobalObject().Get("setup"); setupValue != nil && !goja.IsUndefined(setupValue) && !goja.IsNull(setupValue) { - v.setupGenerator = setupValue + // The bundle's goja runtime entry installs __sanderlingNextAction__ once the + // spec assigned globalThis.actions. Capture it; a spec bundled without the + // runtime entry (raw-JS unit fixtures) leaves it nil and NextAction reports + // ErrNoAction. + if fn := v.runtime.GlobalObject().Get("__sanderlingNextAction__"); fn != nil { + if callable, ok := goja.AssertFunction(fn); ok { + v.nextActionFn = callable + } } return nil @@ -496,36 +511,27 @@ func (v *Verifier) Residuals() map[string]ltl.Formula { return residuals } -// NextAction resolves an action for the current step. The setup generator, -// when registered, runs first; if it yields an action, that wins. When setup -// returns ErrNoAction (all branches empty) the call falls through to the -// root action generator with the existing retry semantics. Setup is consulted -// every step, so state regression (e.g. a logout under fuzz) automatically -// re-engages the precondition. +// NextAction resolves an action for the current step by invoking the bundled +// __sanderlingNextAction__(), which runs the SHARED picker (pick.ts) over the +// shared Pcg. Setup-generator precedence and the 16-attempt retry both live in +// runtime-entry.ts now, so this is a thin call-and-decode. A null result (the +// generator declined to act) reports ErrNoAction. func (v *Verifier) NextAction() (Action, error) { - if v.setupGenerator != nil { - action, err := v.resolveGenerator(v.setupGenerator) - if err == nil { - return action, nil - } - if !errors.Is(err, ErrNoAction) { - return Action{}, err - } - } - if v.actionGenerator == nil { + if v.nextActionFn == nil { return Action{}, ErrNoAction } - const maxRetries = 16 - for range maxRetries { - action, err := v.resolveGenerator(v.actionGenerator) - if err == nil { - return action, nil - } - if !errors.Is(err, ErrNoAction) { - return Action{}, err - } + value, err := v.nextActionFn(goja.Undefined()) + if err != nil { + return Action{}, fmt.Errorf("next action: %w", err) } - return Action{}, ErrNoAction + if value == nil || goja.IsNull(value) || goja.IsUndefined(value) { + return Action{}, ErrNoAction + } + raw, err := json.Marshal(value.Export()) + if err != nil { + return Action{}, fmt.Errorf("marshal action: %w", err) + } + return DecodeAction(raw) } var ErrNoAction = errors.New("verifier: no action available") @@ -541,72 +547,6 @@ func (v *Verifier) formulaThunk(index int) func() (bool, error) { } } -func (v *Verifier) resolveGenerator(generator goja.Value) (Action, error) { - object := generator.ToObject(v.runtime) - if object == nil { - return Action{}, fmt.Errorf("generator is not an object") - } - kindValue := object.Get(tagInternalKind) - if kindValue == nil { - return Action{}, fmt.Errorf("generator missing internal kind tag") - } - switch kindValue.String() { - case internalKindActions: - generateValue := object.Get("generate") - generate, ok := goja.AssertFunction(generateValue) - if !ok { - return Action{}, fmt.Errorf("actions handle missing generate function") - } - result, err := generate(goja.Undefined()) - if err != nil { - return Action{}, fmt.Errorf("generate: %w", err) - } - return v.pickFromResult(result) - case internalKindWeighted: - entries := object.Get("entries").ToObject(v.runtime) - if entries == nil { - return Action{}, fmt.Errorf("weighted handle missing entries") - } - picked, err := v.pickWeighted(entries) - if err != nil { - return Action{}, err - } - return v.resolveGenerator(picked) - case internalKindBuiltinTaps: - return v.generateRandomTap() - case internalKindBuiltinDoubleTaps: - return v.generateRandomDoubleTap() - case internalKindBuiltinTyping: - return v.generateRandomInput() - case internalKindBuiltinSwipes: - return v.generateRandomSwipe() - case internalKindBuiltinWaitOnce: - return Action{Kind: ActionKindWait, DurationMillis: 500}, nil - case internalKindBuiltinPressKey: - return v.generateRandomPressKey() - case internalKindBuiltinLongPresses: - return v.generateRandomLongPress() - case internalKindBuiltinScrolls: - return v.generateRandomScroll() - default: - return Action{}, fmt.Errorf("unknown generator kind %q", kindValue.String()) - } -} - -// generateRandomTap picks a visible, tappable element from the last -// hierarchy snapshot and returns a Tap action targeting its center. -func (v *Verifier) generateRandomTap() (Action, error) { - return v.generateRandomTapKind(ActionKindTap) -} - -// generateRandomDoubleTap is the DoubleTap counterpart of generateRandomTap. -// Real user gestures include double-tap (image zoom, like-to-favorite, -// play/pause); a fuzzer that never emits one cannot exercise either those -// features or the sub-100ms race windows that single-step Tap cadence misses. -func (v *Verifier) generateRandomDoubleTap() (Action, error) { - return v.generateRandomTapKind(ActionKindDoubleTap) -} - // inScope reports whether an element belongs to the app under test. Nodes from // another package (the soft keyboard, system UI, permission dialogs) are out of // scope. An unset app package or an element with no package falls through to in @@ -618,31 +558,6 @@ func (v *Verifier) inScope(element *hierarchy.Element) bool { return element.Package == v.appPackage } -func (v *Verifier) generateRandomTapKind(kind ActionKind) (Action, error) { - if v.lastTree == nil { - return Action{}, ErrNoAction - } - candidates := make([]*hierarchy.Element, 0, len(v.lastTree.Elements)) - for _, element := range v.lastTree.Elements { - if !element.Clickable || !element.Enabled { - continue - } - if !v.inScope(element) { - continue - } - if element.Bounds.Right-element.Bounds.Left <= 0 || element.Bounds.Bottom-element.Bounds.Top <= 0 { - continue - } - candidates = append(candidates, element) - } - if len(candidates) == 0 { - return Action{}, ErrNoAction - } - picked := candidates[v.rng.IntN(len(candidates))] - x, y := picked.Bounds.Center() - return Action{Kind: kind, On: selectorForElement(v.lastTree, picked), X: x, Y: y}, nil -} - // selectorForElement builds a canonical "key:value" selector that resolves // back to the given element via hierarchy.Tree.Find. Prefers resource-id (the // testTag carrier on Android / accessibilityIdentifier on iOS), falling back @@ -684,237 +599,58 @@ func selectorForElement(tree *hierarchy.Tree, element *hierarchy.Element) string return "" } -// inputCorpus is the edge-case string pool the typing builtin draws from to -// stress field parsing: empty, whitespace, overflow length, unicode, numeric -// boundaries, and common injection payloads. -var inputCorpus = []string{ - "", - "a", - strings.Repeat("a", 4096), - "🙂🔥💸", - " ", - "\t\n", - "-1", - "999999999999999999999", - "0.0000001", - "1e10", - "'; DROP TABLE--", - "", - "../../etc/passwd", - "%s%n", - "NaN", -} - -// generateRandomInput picks a visible, editable, enabled element from the last -// hierarchy snapshot and types a random edge-case value into it. The runner -// taps the target coordinates to focus before typing, so this works on both -// native and web with no driver-side dispatch change. -func (v *Verifier) generateRandomInput() (Action, error) { +// candidatesForVerb enumerates the host-side targets a builtin verb may draw +// from, in v.lastTree.Elements ORDER (the order is part of the picker's parity +// contract). The filters are LIFTED from the old Go picker: +// taps/doubleTaps/longPresses: clickable + enabled + positive bounds +// typing: editable + enabled + positive bounds +// scrolls: scrollable attribute + positive bounds +// swipes: any in-scope element +// Every candidate carries the resolving selector so the runner can re-route by +// id/text. Out-of-scope nodes (the soft keyboard, system UI) are always dropped. +func (v *Verifier) candidatesForVerb(verb string) []candidate { if v.lastTree == nil { - return Action{}, ErrNoAction + return nil } - candidates := make([]*hierarchy.Element, 0, len(v.lastTree.Elements)) + var result []candidate for _, element := range v.lastTree.Elements { - if !element.Editable || !element.Enabled { - continue - } if !v.inScope(element) { continue } - if element.Bounds.Right-element.Bounds.Left <= 0 || element.Bounds.Bottom-element.Bounds.Top <= 0 { + if !verbAccepts(verb, element) { continue } - candidates = append(candidates, element) + x, y := element.Bounds.Center() + result = append(result, candidate{ + x: x, + y: y, + width: element.Bounds.Width(), + height: element.Bounds.Height(), + selector: selectorForElement(v.lastTree, element), + }) } - if len(candidates) == 0 { - return Action{}, ErrNoAction - } - picked := candidates[v.rng.IntN(len(candidates))] - x, y := picked.Bounds.Center() - value := inputCorpus[v.rng.IntN(len(inputCorpus))] - return Action{Kind: ActionKindInputText, X: x, Y: y, Text: value}, nil + return result } -// generateRandomSwipe emits a swipe over a random enabled element or the -// whole screen, in a random direction. Returns ErrNoAction only when we have -// no tree to size a gesture off of. -func (v *Verifier) generateRandomSwipe() (Action, error) { - if v.lastTree == nil || len(v.lastTree.Elements) == 0 { - return Action{}, ErrNoAction - } - candidates := make([]*hierarchy.Element, 0, len(v.lastTree.Elements)) - for _, element := range v.lastTree.Elements { - if v.inScope(element) { - candidates = append(candidates, element) - } - } - if len(candidates) == 0 { - return Action{}, ErrNoAction - } - element := candidates[v.rng.IntN(len(candidates))] - cx, cy := element.Bounds.Center() - if cx <= 0 || cy <= 0 { - return Action{}, ErrNoAction - } - // Pick a direction: 0=up 1=down 2=left 3=right; magnitude 200-600 px. - magnitude := 200 + v.rng.IntN(401) - toX, toY := cx, cy - switch v.rng.IntN(4) { - case 0: - toY = cy - magnitude - case 1: - toY = cy + magnitude - case 2: - toX = cx - magnitude - case 3: - toX = cx + magnitude - } - if toX < 0 { - toX = 0 - } - if toY < 0 { - toY = 0 - } - return Action{ - Kind: ActionKindSwipe, - FromX: cx, - FromY: cy, - ToX: toX, - ToY: toY, - DurationMillis: 250, - }, nil +type candidate struct { + x, y int + width, height int + selector string } -// generateRandomLongPress mirrors generateRandomTap: it picks a visible, -// clickable, enabled, in-scope element and targets its center. Real users -// long-press (context menus, reorder handles, multi-select), so a fuzzer that -// never emits one cannot reach those affordances. -func (v *Verifier) generateRandomLongPress() (Action, error) { - return v.generateRandomTapKind(ActionKindLongPress) -} - -// generateRandomScroll picks a scrollable, in-scope container and emits a swipe -// across it in a random direction. -func (v *Verifier) generateRandomScroll() (Action, error) { - if v.lastTree == nil { - return Action{}, ErrNoAction +// verbAccepts applies the per-verb element filter. +func verbAccepts(verb string, element *hierarchy.Element) bool { + positiveBounds := element.Bounds.Width() > 0 && element.Bounds.Height() > 0 + switch verb { + case "taps", "doubleTaps", "longPresses": + return element.Clickable && element.Enabled && positiveBounds + case "typing": + return element.Editable && element.Enabled && positiveBounds + case "scrolls": + return element.Attributes["scrollable"] == "true" && positiveBounds + case "swipes": + return true + default: + return false } - candidates := make([]*hierarchy.Element, 0, len(v.lastTree.Elements)) - for _, element := range v.lastTree.Elements { - if element.Attributes["scrollable"] != "true" { - continue - } - if !v.inScope(element) { - continue - } - if element.Bounds.Width() <= 0 || element.Bounds.Height() <= 0 { - continue - } - candidates = append(candidates, element) - } - if len(candidates) == 0 { - return Action{}, ErrNoAction - } - picked := candidates[v.rng.IntN(len(candidates))] - cx, cy := picked.Bounds.Center() - width := picked.Bounds.Width() - height := picked.Bounds.Height() - directions := []string{"up", "down", "left", "right"} - dir := directions[v.rng.IntN(len(directions))] - toX, toY := cx, cy - // Scroll direction names the content motion; the gesture swipes the - // opposite way. Revealing content below ("down") means dragging the - // finger up, so toY decreases, and likewise for the other directions. - switch dir { - case "down": - toY = cy - (4*height)/10 - case "up": - toY = cy + (4*height)/10 - case "left": - toX = cx + (4*width)/10 - case "right": - toX = cx - (4*width)/10 - } - if toX < 0 { - toX = 0 - } - if toY < 0 { - toY = 0 - } - return Action{ - Kind: ActionKindScroll, - Direction: dir, - FromX: cx, - FromY: cy, - ToX: toX, - ToY: toY, - DurationMillis: 300, - }, nil -} - -func (v *Verifier) generateRandomPressKey() (Action, error) { - // Keep exploration gentle: only "back" for now. Home/menu would navigate - // away from the app under test. - return Action{Kind: ActionKindPressKey, Key: "back"}, nil -} - -func (v *Verifier) pickFromResult(result goja.Value) (Action, error) { - if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { - return Action{}, ErrNoAction - } - object := result.ToObject(v.runtime) - if object == nil { - return Action{}, ErrNoAction - } - lengthValue := object.Get("length") - if lengthValue == nil { - return jsValueToAction(v.runtime, result) - } - length := int(lengthValue.ToInteger()) - if length == 0 { - return Action{}, ErrNoAction - } - pick := v.rng.IntN(length) - return jsValueToAction(v.runtime, object.Get(fmt.Sprintf("%d", pick))) -} - -func (v *Verifier) pickWeighted(entries *goja.Object) (goja.Value, error) { - lengthValue := entries.Get("length") - if lengthValue == nil { - return nil, fmt.Errorf("weighted entries missing length") - } - length := int(lengthValue.ToInteger()) - if length == 0 { - return nil, ErrNoAction - } - - weights := make([]float64, length) - generators := make([]goja.Value, length) - totalWeight := 0.0 - for index := range length { - entry := entries.Get(fmt.Sprintf("%d", index)).ToObject(v.runtime) - if entry == nil { - return nil, fmt.Errorf("weighted entry %d not an array", index) - } - weight := entry.Get("0").ToFloat() - generator := entry.Get("1") - if weight < 0 { - weight = 0 - } - weights[index] = weight - generators[index] = generator - totalWeight += weight - } - if totalWeight == 0 { - return nil, ErrNoAction - } - pick := v.rng.Float64() * totalWeight - cumulative := 0.0 - for index := range length { - cumulative += weights[index] - if pick < cumulative { - return generators[index], nil - } - } - return generators[length-1], nil }