From dc0026ad376bfae19987df53739c99de0b1cdcd2 Mon Sep 17 00:00:00 2001 From: PJ Date: Sun, 31 May 2026 18:07:37 +0530 Subject: [PATCH] feat(verifier): wire longPresses and scrolls generators --- internal/verifier/bindings.go | 53 +++++++++++--- internal/verifier/worker.go | 71 +++++++++++++++++++ pkg/spec/test/folio-balance-predicate.test.ts | 42 ----------- 3 files changed, 116 insertions(+), 50 deletions(-) delete mode 100644 pkg/spec/test/folio-balance-predicate.test.ts diff --git a/internal/verifier/bindings.go b/internal/verifier/bindings.go index 56961b0..eaae32a 100644 --- a/internal/verifier/bindings.go +++ b/internal/verifier/bindings.go @@ -66,14 +66,16 @@ const ( tagInternalKind = "__sanderlingKind" tagSelector = "__sanderlingSelector" - internalKindActions = "actions" - internalKindWeighted = "weighted" - internalKindBuiltinTaps = "taps" - internalKindBuiltinDoubleTaps = "doubleTaps" - internalKindBuiltinTyping = "typing" - internalKindBuiltinSwipes = "swipes" - internalKindBuiltinWaitOnce = "waitOnce" - internalKindBuiltinPressKey = "pressKey" + 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. @@ -110,6 +112,12 @@ func (v *Verifier) installRuntimeBindings() error { 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 } @@ -140,6 +148,12 @@ func (v *Verifier) installRuntimeBindings() error { 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) } @@ -425,6 +439,29 @@ func (v *Verifier) bindDoubleTap(call goja.FunctionCall) goja.Value { 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 { diff --git a/internal/verifier/worker.go b/internal/verifier/worker.go index 69bae15..c8086ac 100644 --- a/internal/verifier/worker.go +++ b/internal/verifier/worker.go @@ -556,6 +556,10 @@ func (v *Verifier) resolveGenerator(generator goja.Value) (Action, error) { 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()) } @@ -712,6 +716,73 @@ func (v *Verifier) generateRandomSwipe() (Action, error) { }, nil } +// 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 + } + 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. diff --git a/pkg/spec/test/folio-balance-predicate.test.ts b/pkg/spec/test/folio-balance-predicate.test.ts deleted file mode 100644 index 869e571..0000000 --- a/pkg/spec/test/folio-balance-predicate.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { - balanceMatchesAddedSum, - type LedgerRow, -} from "../../../examples/folio/sanderling/predicates.ts"; - -function row(key: string, signed: number): LedgerRow { - return { key, signed }; -} - -test("single new row whose signed amount matches the balance delta holds", () => { - const previous: LedgerRow[] = [row("a", 100)]; - const current: LedgerRow[] = [row("a", 100), row("b", 500)]; - assert.equal(balanceMatchesAddedSum(previous, current, 100, 600), true); -}); - -test("two new rows whose sum matches the balance delta holds", () => { - const previous: LedgerRow[] = []; - const current: LedgerRow[] = [row("a", 500), row("b", 300)]; - assert.equal(balanceMatchesAddedSum(previous, current, 0, 800), true); -}); - -test("double-submit case: two identical rows with sum exceeding the delta violates", () => { - const previous: LedgerRow[] = []; - const current: LedgerRow[] = [row("a", 500), row("b", 500)]; - assert.equal(balanceMatchesAddedSum(previous, current, 0, 500), false); -}); - -test("partial-credit case: two new rows whose sum falls short of the delta violates", () => { - const previous: LedgerRow[] = []; - const current: LedgerRow[] = [row("a", 200), row("b", 100)]; - assert.equal(balanceMatchesAddedSum(previous, current, 0, 500), false); -}); - -test("no new rows holds vacuously regardless of balance delta", () => { - const previous: LedgerRow[] = [row("a", 100)]; - const current: LedgerRow[] = [row("a", 100)]; - assert.equal(balanceMatchesAddedSum(previous, current, 100, 100), true); - assert.equal(balanceMatchesAddedSum(previous, current, 100, 999), true); -});