diff --git a/internal/verifier/scope_test.go b/internal/verifier/scope_test.go index 8ce0cb1..469bf7c 100644 --- a/internal/verifier/scope_test.go +++ b/internal/verifier/scope_test.go @@ -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": {"content-desc": "Settings", "bounds": "[461,1503,618,1635]"}, "clickable": true, "enabled": true, "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")) diff --git a/internal/verifier/worker.go b/internal/verifier/worker.go index f0bccc0..5d65cce 100644 --- a/internal/verifier/worker.go +++ b/internal/verifier/worker.go @@ -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 scope } - return element.Package == v.appPackage + 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, diff --git a/internal/verifier/worker_test.go b/internal/verifier/worker_test.go index 68db394..3f95e54 100644 --- a/internal/verifier/worker_test.go +++ b/internal/verifier/worker_test.go @@ -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) } - app := func(top, bottom int) *hierarchy.Element { - return &hierarchy.Element{ResourceID: "app/field", Bounds: hierarchy.Bounds{Right: 1080, Top: top, Bottom: bottom}} + 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) + } + 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") + } }