mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
Both hosts routed verbs themselves and both policies enumerated their own actions, and all four drifted. Web sent `swipes` to scrollable containers only, so swipe-to-dismiss on a list row was reachable on native and unreachable on web; the model policy folded gestures its own way and could not reach what the seeded picker drew. A host now reports facts about every element and never decides which verb may act on it: targets.ts acceptsTarget owns that for both. pick.ts builtinCandidates is the single enumeration, and the model policy reads it through __sanderlingEnumerateBuiltin__ instead of reimplementing it in Go. Gesture verbs change with it: scrolls stay vertical over scrollable containers, swipes go free-form in all four directions from any element with real bounds. Claude-Session: https://claude.ai/code/session_01Fj4wJUikdABuMQEETwW55J
96 lines
3.6 KiB
Go
96 lines
3.6 KiB
Go
package verifier
|
|
|
|
import (
|
|
"slices"
|
|
"testing"
|
|
|
|
"github.com/priyanshujain/sanderling/internal/hierarchy"
|
|
)
|
|
|
|
// TestTargetsReportFactsWithoutFiltering pins the native host's half of the
|
|
// split introduced to stop the two hosts drifting: it reports what an element
|
|
// IS and never decides which verb may act on it. The verb decision is one shared
|
|
// rule (pkg/spec/src/targets.ts) both hosts consume, asserted across engines by
|
|
// TestHostsAgreeOnTargetEligibility.
|
|
func TestTargetsReportFactsWithoutFiltering(t *testing.T) {
|
|
tree, err := hierarchy.Parse(hostParityTreeJSON)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
v := &Verifier{lastTree: tree}
|
|
targets := v.targets()
|
|
if len(targets) != len(hostParityScreen) {
|
|
t.Fatalf("targets() returned %d elements, want all %d in the tree",
|
|
len(targets), len(hostParityScreen))
|
|
}
|
|
for index, target := range targets {
|
|
want := hostParityScreen[index]
|
|
got := []bool{
|
|
target.clickable, target.enabled, target.editable, target.scrollable,
|
|
}
|
|
expected := []bool{
|
|
want.clickable, want.enabled, want.editable, want.scrollable,
|
|
}
|
|
if !slices.Equal(got, expected) {
|
|
t.Errorf("%s clickable/enabled/editable/scrollable = %v, want %v",
|
|
want.name, got, expected)
|
|
}
|
|
positiveBounds := target.width > 0 && target.height > 0
|
|
if positiveBounds != want.positiveBounds {
|
|
t.Errorf("%s positive bounds = %v, want %v",
|
|
want.name, positiveBounds, want.positiveBounds)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
return scope[element]
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
// 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")
|
|
}
|
|
}
|