mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
refactor(verifier): goja host + shared picker replace the duplicate Go picker
This commit is contained in:
1 parent
59cb77d29c
commit
411aefe25f
2 files changed
+147
-583
No files matched your search
+59
-231
@@ -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
|
||||
}
|
||||
+88
-352
@@ -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--",
|
||||
"<script>alert(1)</script>",
|
||||
"../../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
|
||||
}
|
||||
Reference in new issue
Block a user