mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
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:
1 parent
71b521fa1d
commit
505367f42b
3 files changed
+93
-103
No files matched your search
@@ -48,18 +48,19 @@ func TestTaps_ExcludeOffAppPackage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTaps_ExcludeKeyboardRegionNoPackageKey proves the region exclusion catches
|
||||
// a keyboard key that carries no package (Gboard's "Settings" key is a bare
|
||||
// FrameLayout with a content-desc and no resource-id, so the package filter
|
||||
// alone misses it). The IME's own elements set the keyboard's top edge; any
|
||||
// candidate below it is dropped, leaving only the in-app button above it.
|
||||
// TestTaps_ExcludeKeyboardRegionNoPackageKey proves a keyboard key that carries
|
||||
// no package (the keyboard's "Settings" key is a bare node with a content-desc
|
||||
// and no package) is still dropped: it is a child of the IME window, so it
|
||||
// inherits the IME package as its owner and falls out of scope. A per-element
|
||||
// package check would admit it. Only the in-app button remains a target.
|
||||
func TestTaps_ExcludeKeyboardRegionNoPackageKey(t *testing.T) {
|
||||
const treeJSON = `{
|
||||
"attributes": {"resource-id": "root", "bounds": "[0,0,1080,2400]", "package": "com.folio"},
|
||||
"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": []}
|
||||
]}
|
||||
]
|
||||
}`
|
||||
verifier := newVerifier(t, WithAppPackage("com.folio"))
|
||||
|
||||
+44
-64
@@ -7,9 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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
|
||||
// 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, preserving behavior on platforms that omit the attribute (e.g. iOS).
|
||||
func (v *Verifier) inScope(element *hierarchy.Element) bool {
|
||||
if v.appPackage == "" || element.Package == "" {
|
||||
return true
|
||||
// frameworkPackage is the AOSP framework package. Both the app's own window
|
||||
// (android:id/content) and system chrome carry it, so it is treated as neutral
|
||||
// (transparent) when deciding which window owns a node, rather than as a foreign
|
||||
// package that would put the app's content out of scope.
|
||||
const frameworkPackage = "android"
|
||||
|
||||
// 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
|
||||
@@ -649,72 +679,22 @@ func selectorForElement(tree *hierarchy.Tree, element *hierarchy.Element) string
|
||||
// swipes: any in-scope element with positive bounds
|
||||
//
|
||||
// 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.
|
||||
// keyboardRegionTop returns the Y above which the on-screen keyboard occupies
|
||||
// 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")
|
||||
}
|
||||
|
||||
// id/text. Out-of-scope nodes (the soft keyboard, system UI, the launcher) are
|
||||
// dropped by scopedElements.
|
||||
func (v *Verifier) candidatesForVerb(verb string) []candidate {
|
||||
if v.lastTree == nil {
|
||||
return nil
|
||||
}
|
||||
// The keyboard-region exclusion is part of keeping exploration in the app,
|
||||
// so it is opt-in with app scoping: an unscoped run keeps every node.
|
||||
keyboardTop := math.MaxInt
|
||||
if v.appPackage != "" {
|
||||
keyboardTop = keyboardRegionTop(v.lastTree)
|
||||
}
|
||||
scope := v.scopedElements()
|
||||
var result []candidate
|
||||
for _, element := range v.lastTree.Elements {
|
||||
if !v.inScope(element) {
|
||||
if !scope[element] {
|
||||
continue
|
||||
}
|
||||
if !verbAccepts(verb, element) {
|
||||
continue
|
||||
}
|
||||
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{
|
||||
x: x,
|
||||
y: y,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package verifier
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"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
|
||||
// keyboard keys, especially the guard that ignores the IME's full-screen decor
|
||||
// view (which otherwise reports a huge bounds and would push the keyboard line
|
||||
// to the top of the screen, excluding the whole app).
|
||||
func TestKeyboardRegionTop(t *testing.T) {
|
||||
ime := func(top, bottom int) *hierarchy.Element {
|
||||
return &hierarchy.Element{
|
||||
ResourceID: "com.google.android.inputmethod.latin:id/keyboard_holder",
|
||||
Bounds: hierarchy.Bounds{Right: 1080, Top: top, Bottom: bottom},
|
||||
// TestScopedElements is the core of keeping the fuzzer in the app. It checks
|
||||
// the window-ownership rule against a realistic tree: the app window carries no
|
||||
// package (Compose), even under android:id/content; the soft keyboard and system
|
||||
// UI are separate windows with concrete packages, and their empty-package child
|
||||
// wrappers (a keyboard "Settings" key) must inherit the foreign owner and drop
|
||||
// out -- the exact node that used to leak in and navigate to system Settings.
|
||||
func TestScopedElements(t *testing.T) {
|
||||
const treeJSON = `{
|
||||
"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 &hierarchy.Element{ResourceID: "app/field", Bounds: hierarchy.Bounds{Right: 1080, Top: top, Bottom: bottom}}
|
||||
return scope[element]
|
||||
}
|
||||
|
||||
t.Run("no keyboard yields sentinel", func(t *testing.T) {
|
||||
tree := &hierarchy.Tree{Elements: []*hierarchy.Element{app(0, 2400)}}
|
||||
if got := keyboardRegionTop(tree); got != math.MaxInt {
|
||||
t.Errorf("keyboardRegionTop = %d, want MaxInt with no keyboard", got)
|
||||
// App nodes carry no package and stay in scope, even under the android
|
||||
// framework content wrapper.
|
||||
for _, selector := range []string{"id:LoginEmail", "id:AccountNameField"} {
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decor-only view yields sentinel", func(t *testing.T) {
|
||||
tree := &hierarchy.Tree{Elements: []*hierarchy.Element{app(0, 2400), ime(0, 2400)}}
|
||||
if got := keyboardRegionTop(tree); got != math.MaxInt {
|
||||
t.Errorf("keyboardRegionTop = %d, want MaxInt when only a full-screen IME decor view is present", 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.
|
||||
if inScope("desc:Settings") {
|
||||
t.Error("keyboard Settings key must be out of scope (owned by the IME window)")
|
||||
}
|
||||
if inScope("id:nav") {
|
||||
t.Error("system UI node must be out of scope")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user