mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-04 20:17:09 +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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math/big"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
@@ -57,20 +59,7 @@ type formulaSpec struct {
|
|||||||
const (
|
const (
|
||||||
tagFormula = "__sanderlingFormula"
|
tagFormula = "__sanderlingFormula"
|
||||||
tagFormulaSpecIndex = "__sanderlingFormulaSpec"
|
tagFormulaSpecIndex = "__sanderlingFormulaSpec"
|
||||||
tagActionGenerator = "__sanderlingActionGenerator"
|
|
||||||
tagInternalKind = "__sanderlingKind"
|
|
||||||
tagSelector = "__sanderlingSelector"
|
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.
|
// 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 {
|
if err := sanderling.Set("eventually", v.bindEventually); err != nil {
|
||||||
return err
|
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 {
|
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
|
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"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
"math/rand/v2"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
@@ -23,9 +21,11 @@ type Verifier struct {
|
|||||||
formulas []*formulaState
|
formulas []*formulaState
|
||||||
formulaSpecs []formulaSpec
|
formulaSpecs []formulaSpec
|
||||||
|
|
||||||
properties map[string]int // property name -> formula-spec index
|
properties map[string]int // property name -> formula-spec index
|
||||||
actionGenerator goja.Value
|
|
||||||
setupGenerator goja.Value
|
// 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
|
evaluators map[string]*ltl.Evaluator
|
||||||
|
|
||||||
@@ -41,14 +41,24 @@ type Verifier struct {
|
|||||||
runStart time.Time
|
runStart time.Time
|
||||||
|
|
||||||
appPackage string
|
appPackage string
|
||||||
|
platform string
|
||||||
rng *rand.Rand
|
seed uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
type Option func(*Verifier)
|
type Option func(*Verifier)
|
||||||
|
|
||||||
func WithRand(rng *rand.Rand) Option {
|
// WithSeed sets the 64-bit seed the JS picker constructs its Pcg from
|
||||||
return func(v *Verifier) { v.rng = rng }
|
// (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.
|
// 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{},
|
evaluators: map[string]*ltl.Evaluator{},
|
||||||
priorVerdicts: map[string]ltl.Verdict{},
|
priorVerdicts: map[string]ltl.Verdict{},
|
||||||
witnesses: map[string]Witness{},
|
witnesses: map[string]Witness{},
|
||||||
rng: rand.New(rand.NewPCG(0, 0)),
|
platform: "android",
|
||||||
}
|
}
|
||||||
for _, option := range options {
|
for _, option := range options {
|
||||||
option(verifier)
|
option(verifier)
|
||||||
}
|
}
|
||||||
|
if verifier.platform == "" {
|
||||||
|
verifier.platform = "android"
|
||||||
|
}
|
||||||
if err := verifier.installRuntimeBindings(); err != nil {
|
if err := verifier.installRuntimeBindings(); err != nil {
|
||||||
return nil, fmt.Errorf("install bindings: %w", err)
|
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) {
|
// The bundle's goja runtime entry installs __sanderlingNextAction__ once the
|
||||||
v.actionGenerator = actionsValue
|
// 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 setupValue := v.runtime.GlobalObject().Get("setup"); setupValue != nil && !goja.IsUndefined(setupValue) && !goja.IsNull(setupValue) {
|
if fn := v.runtime.GlobalObject().Get("__sanderlingNextAction__"); fn != nil {
|
||||||
v.setupGenerator = setupValue
|
if callable, ok := goja.AssertFunction(fn); ok {
|
||||||
|
v.nextActionFn = callable
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -496,36 +511,27 @@ func (v *Verifier) Residuals() map[string]ltl.Formula {
|
|||||||
return residuals
|
return residuals
|
||||||
}
|
}
|
||||||
|
|
||||||
// NextAction resolves an action for the current step. The setup generator,
|
// NextAction resolves an action for the current step by invoking the bundled
|
||||||
// when registered, runs first; if it yields an action, that wins. When setup
|
// __sanderlingNextAction__(), which runs the SHARED picker (pick.ts) over the
|
||||||
// returns ErrNoAction (all branches empty) the call falls through to the
|
// shared Pcg. Setup-generator precedence and the 16-attempt retry both live in
|
||||||
// root action generator with the existing retry semantics. Setup is consulted
|
// runtime-entry.ts now, so this is a thin call-and-decode. A null result (the
|
||||||
// every step, so state regression (e.g. a logout under fuzz) automatically
|
// generator declined to act) reports ErrNoAction.
|
||||||
// re-engages the precondition.
|
|
||||||
func (v *Verifier) NextAction() (Action, error) {
|
func (v *Verifier) NextAction() (Action, error) {
|
||||||
if v.setupGenerator != nil {
|
if v.nextActionFn == 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 {
|
|
||||||
return Action{}, ErrNoAction
|
return Action{}, ErrNoAction
|
||||||
}
|
}
|
||||||
const maxRetries = 16
|
value, err := v.nextActionFn(goja.Undefined())
|
||||||
for range maxRetries {
|
if err != nil {
|
||||||
action, err := v.resolveGenerator(v.actionGenerator)
|
return Action{}, fmt.Errorf("next action: %w", err)
|
||||||
if err == nil {
|
|
||||||
return action, nil
|
|
||||||
}
|
|
||||||
if !errors.Is(err, ErrNoAction) {
|
|
||||||
return Action{}, 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")
|
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
|
// 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
|
// 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
|
// 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
|
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
|
// selectorForElement builds a canonical "key:value" selector that resolves
|
||||||
// back to the given element via hierarchy.Tree.Find. Prefers resource-id (the
|
// back to the given element via hierarchy.Tree.Find. Prefers resource-id (the
|
||||||
// testTag carrier on Android / accessibilityIdentifier on iOS), falling back
|
// testTag carrier on Android / accessibilityIdentifier on iOS), falling back
|
||||||
@@ -684,237 +599,58 @@ func selectorForElement(tree *hierarchy.Tree, element *hierarchy.Element) string
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// inputCorpus is the edge-case string pool the typing builtin draws from to
|
// candidatesForVerb enumerates the host-side targets a builtin verb may draw
|
||||||
// stress field parsing: empty, whitespace, overflow length, unicode, numeric
|
// from, in v.lastTree.Elements ORDER (the order is part of the picker's parity
|
||||||
// boundaries, and common injection payloads.
|
// contract). The filters are LIFTED from the old Go picker:
|
||||||
var inputCorpus = []string{
|
// taps/doubleTaps/longPresses: clickable + enabled + positive bounds
|
||||||
"",
|
// typing: editable + enabled + positive bounds
|
||||||
"a",
|
// scrolls: scrollable attribute + positive bounds
|
||||||
strings.Repeat("a", 4096),
|
// 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.
|
||||||
"\t\n",
|
func (v *Verifier) candidatesForVerb(verb string) []candidate {
|
||||||
"-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) {
|
|
||||||
if v.lastTree == nil {
|
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 {
|
for _, element := range v.lastTree.Elements {
|
||||||
if !element.Editable || !element.Enabled {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !v.inScope(element) {
|
if !v.inScope(element) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if element.Bounds.Right-element.Bounds.Left <= 0 || element.Bounds.Bottom-element.Bounds.Top <= 0 {
|
if !verbAccepts(verb, element) {
|
||||||
continue
|
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 result
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateRandomSwipe emits a swipe over a random enabled element or the
|
type candidate struct {
|
||||||
// whole screen, in a random direction. Returns ErrNoAction only when we have
|
x, y int
|
||||||
// no tree to size a gesture off of.
|
width, height int
|
||||||
func (v *Verifier) generateRandomSwipe() (Action, error) {
|
selector string
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateRandomLongPress mirrors generateRandomTap: it picks a visible,
|
// verbAccepts applies the per-verb element filter.
|
||||||
// clickable, enabled, in-scope element and targets its center. Real users
|
func verbAccepts(verb string, element *hierarchy.Element) bool {
|
||||||
// long-press (context menus, reorder handles, multi-select), so a fuzzer that
|
positiveBounds := element.Bounds.Width() > 0 && element.Bounds.Height() > 0
|
||||||
// never emits one cannot reach those affordances.
|
switch verb {
|
||||||
func (v *Verifier) generateRandomLongPress() (Action, error) {
|
case "taps", "doubleTaps", "longPresses":
|
||||||
return v.generateRandomTapKind(ActionKindLongPress)
|
return element.Clickable && element.Enabled && positiveBounds
|
||||||
}
|
case "typing":
|
||||||
|
return element.Editable && element.Enabled && positiveBounds
|
||||||
// generateRandomScroll picks a scrollable, in-scope container and emits a swipe
|
case "scrolls":
|
||||||
// across it in a random direction.
|
return element.Attributes["scrollable"] == "true" && positiveBounds
|
||||||
func (v *Verifier) generateRandomScroll() (Action, error) {
|
case "swipes":
|
||||||
if v.lastTree == nil {
|
return true
|
||||||
return Action{}, ErrNoAction
|
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