refactor(verifier): scope action candidates by window ownership

Replaces the leaky per-element package check and the keyboard-region Y
heuristic with one rule: walk the window tree propagating each node's owning
package (empty and the neutral android framework package are transparent); a
node is in scope only when no concrete foreign package owns it (the app's own
window carries no package on Compose apps) or the owner is the app package.

This drops whole foreign windows (soft keyboard, system UI, launcher) AND
their empty-package child wrappers -- e.g. a keyboard's 'Settings' key, which
the old empty-package-is-in-scope rule admitted and which navigated out of the
app. Deletes keyboardRegionTop/isInputMethodElement.
This commit is contained in:
pj committed 2026-06-10 21:53:21 +05:30
1 parent 71b521fa1d
commit 505367f42b
3 files changed
+93 -103

No files matched your search

+7 -6
View File
@@ -48,18 +48,19 @@ func TestTaps_ExcludeOffAppPackage(t *testing.T) {
} }
} }
// TestTaps_ExcludeKeyboardRegionNoPackageKey proves the region exclusion catches // TestTaps_ExcludeKeyboardRegionNoPackageKey proves a keyboard key that carries
// a keyboard key that carries no package (Gboard's "Settings" key is a bare // no package (the keyboard's "Settings" key is a bare node with a content-desc
// FrameLayout with a content-desc and no resource-id, so the package filter // and no package) is still dropped: it is a child of the IME window, so it
// alone misses it). The IME's own elements set the keyboard's top edge; any // inherits the IME package as its owner and falls out of scope. A per-element
// candidate below it is dropped, leaving only the in-app button above it. // package check would admit it. Only the in-app button remains a target.
func TestTaps_ExcludeKeyboardRegionNoPackageKey(t *testing.T) { func TestTaps_ExcludeKeyboardRegionNoPackageKey(t *testing.T) {
const treeJSON = `{ const treeJSON = `{
"attributes": {"resource-id": "root", "bounds": "[0,0,1080,2400]", "package": "com.folio"}, "attributes": {"resource-id": "root", "bounds": "[0,0,1080,2400]", "package": "com.folio"},
"children": [ "children": [
{"attributes": {"testTag": "SubmitButton", "bounds": "[100,400,500,500]", "package": "com.folio"}, "clickable": true, "enabled": true, "children": []}, {"attributes": {"testTag": "SubmitButton", "bounds": "[100,400,500,500]", "package": "com.folio"}, "clickable": true, "enabled": true, "children": []},
{"attributes": {"resource-id": "com.google.android.inputmethod.latin:id/keyboard_holder", "bounds": "[0,1503,1080,2268]"}, "children": []}, {"attributes": {"resource-id": "com.google.android.inputmethod.latin:id/keyboard_holder", "bounds": "[0,1503,1080,2268]"}, "children": [
{"attributes": {"content-desc": "Settings", "bounds": "[461,1503,618,1635]"}, "clickable": true, "enabled": true, "children": []} {"attributes": {"content-desc": "Settings", "bounds": "[461,1503,618,1635]"}, "clickable": true, "enabled": true, "children": []}
]}
] ]
}` }`
verifier := newVerifier(t, WithAppPackage("com.folio")) verifier := newVerifier(t, WithAppPackage("com.folio"))
+44 -64
View File
@@ -7,9 +7,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"maps" "maps"
"math"
"sort" "sort"
"strings"
"time" "time"
"github.com/dop251/goja" "github.com/dop251/goja"
@@ -587,15 +585,47 @@ func (v *Verifier) formulaThunk(index int) func() (bool, error) {
} }
} }
// inScope reports whether an element belongs to the app under test. Nodes from // frameworkPackage is the AOSP framework package. Both the app's own window
// another package (the soft keyboard, system UI, permission dialogs) are out of // (android:id/content) and system chrome carry it, so it is treated as neutral
// scope. An unset app package or an element with no package falls through to in // (transparent) when deciding which window owns a node, rather than as a foreign
// scope, preserving behavior on platforms that omit the attribute (e.g. iOS). // package that would put the app's content out of scope.
func (v *Verifier) inScope(element *hierarchy.Element) bool { const frameworkPackage = "android"
if v.appPackage == "" || element.Package == "" {
return true // scopedElements returns the set of elements that belong to the app under test.
// It walks the window tree propagating each node's owning package: a node's
// owner is the nearest ancestor-or-self with a concrete package (empty and the
// neutral android framework package are transparent). A node is in scope when no
// concrete foreign package owns it -- the app's own window carries no package on
// Compose apps -- or the owner is the app package itself. This drops whole
// foreign windows (the soft keyboard, system UI, the launcher) AND their
// empty-package child wrappers, e.g. a keyboard's "Settings" key, which a
// per-element package check admits because the wrapper itself has no package.
//
// With no app package configured (iOS/web, or an unscoped run) every node is in
// scope, preserving prior behavior.
func (v *Verifier) scopedElements() map[*hierarchy.Element]bool {
scope := make(map[*hierarchy.Element]bool, len(v.lastTree.Elements))
unscoped := v.appPackage == ""
if v.lastTree.Root == nil {
for _, element := range v.lastTree.Elements {
scope[element] = true
} }
return element.Package == v.appPackage return scope
}
var walk func(node *hierarchy.Node, owner string)
walk = func(node *hierarchy.Node, owner string) {
if pkg := node.Element.Package; pkg != "" && pkg != frameworkPackage {
owner = pkg
}
if unscoped || owner == "" || owner == v.appPackage {
scope[&node.Element] = true
}
for _, child := range node.Children {
walk(child, owner)
}
}
walk(v.lastTree.Root, "")
return scope
} }
// selectorForElement builds a canonical "key:value" selector that resolves // selectorForElement builds a canonical "key:value" selector that resolves
@@ -649,72 +679,22 @@ func selectorForElement(tree *hierarchy.Tree, element *hierarchy.Element) string
// swipes: any in-scope element with positive bounds // swipes: any in-scope element with positive bounds
// //
// Every candidate carries the resolving selector so the runner can re-route by // 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. // id/text. Out-of-scope nodes (the soft keyboard, system UI, the launcher) are
// keyboardRegionTop returns the Y above which the on-screen keyboard occupies // dropped by scopedElements.
// the display, or math.MaxInt when no keyboard is up. Taps below this line land
// on the keyboard, not the app, so candidates there are dropped: a tap on a key
// (e.g. Gboard's "Settings") navigates out of the app under test. The IME's
// root view reports inflated full-screen bounds, so only IME elements anchored
// in the lower half of the screen set the line.
func keyboardRegionTop(tree *hierarchy.Tree) int {
if tree == nil {
return math.MaxInt
}
screenBottom := 0
for _, element := range tree.Elements {
if element.Bounds.Bottom > screenBottom {
screenBottom = element.Bounds.Bottom
}
}
top := math.MaxInt
for _, element := range tree.Elements {
if !isInputMethodElement(element) {
continue
}
if element.Bounds.Top*2 < screenBottom {
continue // a full-screen IME decor view, not the keyboard itself
}
if element.Bounds.Top < top {
top = element.Bounds.Top
}
}
return top
}
// isInputMethodElement reports whether an element belongs to the soft keyboard,
// identified by its IME resource-id or package. iOS keyboards do not match, so
// this exclusion is effectively Android-only.
func isInputMethodElement(element *hierarchy.Element) bool {
return strings.Contains(element.ResourceID, "inputmethod") ||
strings.Contains(element.Package, "inputmethod")
}
func (v *Verifier) candidatesForVerb(verb string) []candidate { func (v *Verifier) candidatesForVerb(verb string) []candidate {
if v.lastTree == nil { if v.lastTree == nil {
return nil return nil
} }
// The keyboard-region exclusion is part of keeping exploration in the app, scope := v.scopedElements()
// so it is opt-in with app scoping: an unscoped run keeps every node.
keyboardTop := math.MaxInt
if v.appPackage != "" {
keyboardTop = keyboardRegionTop(v.lastTree)
}
var result []candidate var result []candidate
for _, element := range v.lastTree.Elements { for _, element := range v.lastTree.Elements {
if !v.inScope(element) { if !scope[element] {
continue continue
} }
if !verbAccepts(verb, element) { if !verbAccepts(verb, element) {
continue continue
} }
x, y := element.Bounds.Center() x, y := element.Bounds.Center()
// Drop candidates the on-screen keyboard occludes: a tap there hits a
// key, not the app, and keys like Gboard's "Settings" navigate out of
// the app under test. The keyboard's own elements carry no package, so
// the scope filter alone misses them.
if y >= keyboardTop {
continue
}
result = append(result, candidate{ result = append(result, candidate{
x: x, x: x,
y: y, y: y,
+42 -33
View File
@@ -1,7 +1,6 @@
package verifier package verifier
import ( import (
"math"
"testing" "testing"
"github.com/priyanshujain/sanderling/internal/hierarchy" "github.com/priyanshujain/sanderling/internal/hierarchy"
@@ -23,43 +22,53 @@ func TestVerbAcceptsSwipeRequiresPositiveBounds(t *testing.T) {
} }
} }
// TestKeyboardRegionTop covers the region detection that keeps the fuzzer off // TestScopedElements is the core of keeping the fuzzer in the app. It checks
// keyboard keys, especially the guard that ignores the IME's full-screen decor // the window-ownership rule against a realistic tree: the app window carries no
// view (which otherwise reports a huge bounds and would push the keyboard line // package (Compose), even under android:id/content; the soft keyboard and system
// to the top of the screen, excluding the whole app). // UI are separate windows with concrete packages, and their empty-package child
func TestKeyboardRegionTop(t *testing.T) { // wrappers (a keyboard "Settings" key) must inherit the foreign owner and drop
ime := func(top, bottom int) *hierarchy.Element { // out -- the exact node that used to leak in and navigate to system Settings.
return &hierarchy.Element{ func TestScopedElements(t *testing.T) {
ResourceID: "com.google.android.inputmethod.latin:id/keyboard_holder", const treeJSON = `{
Bounds: hierarchy.Bounds{Right: 1080, Top: top, Bottom: bottom}, "attributes": {"bounds": "[0,0,1080,2400]"},
"children": [
{"attributes": {"resource-id": "LoginEmail", "bounds": "[0,100,1080,200]"}, "children": []},
{"attributes": {"resource-id": "android:id/content", "bounds": "[0,0,1080,2400]"}, "children": [
{"attributes": {"resource-id": "AccountNameField", "bounds": "[0,300,1080,400]"}, "children": []}
]},
{"attributes": {"resource-id": "com.oplus.securitykeyboard:id/keyboard", "bounds": "[0,1503,1080,2268]"}, "children": [
{"attributes": {"content-desc": "Settings", "bounds": "[461,1503,618,1635]"}, "children": []}
]},
{"attributes": {"resource-id": "com.android.systemui:id/nav", "bounds": "[0,2268,1080,2400]"}, "children": []}
]
}`
tree, err := hierarchy.Parse(treeJSON)
if err != nil {
t.Fatal(err)
} }
v := &Verifier{appPackage: "app.folio", lastTree: tree}
scope := v.scopedElements()
inScope := func(selector string) bool {
element := tree.Find(selector)
if element == nil {
t.Fatalf("element %q not found in tree", selector)
} }
app := func(top, bottom int) *hierarchy.Element { return scope[element]
return &hierarchy.Element{ResourceID: "app/field", Bounds: hierarchy.Bounds{Right: 1080, Top: top, Bottom: bottom}}
} }
t.Run("no keyboard yields sentinel", func(t *testing.T) { // App nodes carry no package and stay in scope, even under the android
tree := &hierarchy.Tree{Elements: []*hierarchy.Element{app(0, 2400)}} // framework content wrapper.
if got := keyboardRegionTop(tree); got != math.MaxInt { for _, selector := range []string{"id:LoginEmail", "id:AccountNameField"} {
t.Errorf("keyboardRegionTop = %d, want MaxInt with no keyboard", got) if !inScope(selector) {
t.Errorf("%s should be in scope (app window)", selector)
} }
})
t.Run("decor view ignored, real keyboard sets the line", func(t *testing.T) {
tree := &hierarchy.Tree{Elements: []*hierarchy.Element{
app(0, 2400),
ime(0, 2400), // full-screen IME decor view: must be ignored
ime(1503, 2268), // the actual keyboard
}}
if got := keyboardRegionTop(tree); got != 1503 {
t.Errorf("keyboardRegionTop = %d, want 1503 (keyboard top, not the decor view's 0)", got)
} }
}) // The keyboard's empty-package "Settings" key inherits the IME window owner
// and drops out; the system UI node drops out by its own package.
t.Run("decor-only view yields sentinel", func(t *testing.T) { if inScope("desc:Settings") {
tree := &hierarchy.Tree{Elements: []*hierarchy.Element{app(0, 2400), ime(0, 2400)}} t.Error("keyboard Settings key must be out of scope (owned by the IME window)")
if got := keyboardRegionTop(tree); got != math.MaxInt { }
t.Errorf("keyboardRegionTop = %d, want MaxInt when only a full-screen IME decor view is present", got) if inScope("id:nav") {
t.Error("system UI node must be out of scope")
} }
})
} }