mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17: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
|
// 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
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user