Merge branch 'master' into fix-violation-step-attribution

This commit is contained in:
pj committed 2026-06-05 23:45:49 +05:30
commit 56844df028
19 files changed
+981 -190

No files matched your search

+17
View File
@@ -193,6 +193,23 @@ func (d *Driver) InputText(_ context.Context, text string) error {
)
}
// EraseText clears the focused field. InputText above already replaces via
// select-all, so the character count is not needed to bound the deletion.
func (d *Driver) EraseText(_ context.Context, _ int) error {
return chromedp.Run(d.tabCtx,
chromedp.ActionFunc(func(ctx context.Context) error {
if err := chromedp.Evaluate(`
(function() {
const el = document.activeElement;
if (el && typeof el.select === 'function') el.select();
})()`, nil).Do(ctx); err != nil {
return err
}
return input.InsertText("").Do(ctx)
}),
)
}
func (d *Driver) Swipe(_ context.Context, fromX, fromY, toX, toY int, duration time.Duration) error {
millis := max(duration.Milliseconds(), 50)
script := fmt.Sprintf(`
+4
View File
@@ -20,6 +20,10 @@ type DeviceDriver interface {
DoubleTap(ctx context.Context, x, y int) error
DoubleTapSelector(ctx context.Context, selector string) error
InputText(ctx context.Context, text string) error
// EraseText deletes characterCount characters from the focused field.
// The runner calls it before InputText so the verb replaces existing
// content instead of appending to it.
EraseText(ctx context.Context, characterCount int) error
Swipe(ctx context.Context, fromX, fromY, toX, toY int, duration time.Duration) error
PressKey(ctx context.Context, key string) error
LongPress(ctx context.Context, x, y int) error
+23 -13
View File
@@ -19,6 +19,7 @@ const (
ActionDoubleTap ActionKind = "double_tap"
ActionDoubleTapSelector ActionKind = "double_tap_selector"
ActionInputText ActionKind = "input_text"
ActionEraseText ActionKind = "erase_text"
ActionSwipe ActionKind = "swipe"
ActionPressKey ActionKind = "press_key"
ActionLongPress ActionKind = "long_press"
@@ -32,19 +33,20 @@ const (
)
type Action struct {
Kind ActionKind
BundleID string
ClearState bool
X, Y int
FromX, FromY int
ToX, ToY int
Duration time.Duration
Selector string
Text string
Key string
LogLevel string
LogSince time.Time
Idle time.Duration
Kind ActionKind
BundleID string
ClearState bool
X, Y int
FromX, FromY int
ToX, ToY int
Duration time.Duration
Selector string
Text string
CharacterCount int
Key string
LogLevel string
LogSince time.Time
Idle time.Duration
}
// Driver is an in-memory Driver implementation for unit tests.
@@ -210,6 +212,14 @@ func (d *Driver) InputText(ctx context.Context, text string) error {
return nil
}
func (d *Driver) EraseText(ctx context.Context, characterCount int) error {
if err := d.failure(ActionEraseText); err != nil {
return err
}
d.record(Action{Kind: ActionEraseText, CharacterCount: characterCount})
return nil
}
func (d *Driver) Swipe(ctx context.Context, fromX, fromY, toX, toY int, duration time.Duration) error {
if err := d.failure(ActionSwipe); err != nil {
return err
+14 -5
View File
@@ -104,14 +104,18 @@ func (c *Client) TapSelector(ctx context.Context, selector string) error {
return err
}
// doubleTapGap is the inter-tap delay for DoubleTap: short enough to land both
// events inside a sub-100 ms race window, long enough for the sidecar to
// serialize two MotionEvent streams. The sidecar exposes no native double-tap
// RPC, so the gesture is two Taps with this gap.
// doubleTapGap is the inter-tap delay for the selector fallback: short enough
// to land both events inside a sub-100 ms race window, long enough for the
// sidecar to serialize two MotionEvent streams.
const doubleTapGap = 50 * time.Millisecond
// DoubleTap dispatches the native RPC so the backend can land both taps as
// close together as the platform allows. Composing two Tap round trips from
// here spreads them by hundreds of milliseconds on iOS, wide enough for
// navigation to interleave between the taps.
func (c *Client) DoubleTap(ctx context.Context, x, y int) error {
return doubleTap(ctx, func() error { return c.Tap(ctx, x, y) })
_, err := c.stub.DoubleTap(ctx, &driverpb.Point{X: int32(x), Y: int32(y)})
return err
}
func (c *Client) DoubleTapSelector(ctx context.Context, selector string) error {
@@ -137,6 +141,11 @@ func (c *Client) InputText(ctx context.Context, text string) error {
return err
}
func (c *Client) EraseText(ctx context.Context, characterCount int) error {
_, err := c.stub.EraseText(ctx, &driverpb.EraseTextRequest{CharacterCount: int32(characterCount)})
return err
}
func (c *Client) Swipe(ctx context.Context, fromX, fromY, toX, toY int, duration time.Duration) error {
_, err := c.stub.Swipe(ctx, &driverpb.SwipeRequest{
From: &driverpb.Point{X: int32(fromX), Y: int32(fromY)},
+109 -72
View File
@@ -75,6 +75,7 @@ type Element struct {
type Node struct {
Element
Children []*Node `json:"-"`
tree *Tree
}
// Tree is a flat collection of every node in a hierarchy dump, in pre-order.
@@ -180,7 +181,7 @@ func Parse(text string) (*Tree, error) {
}
func walkNode(node *treeNodeJSON, tree *Tree) *Node {
n := &Node{Element: *elementFromNode(node)}
n := &Node{Element: *elementFromNode(node), tree: tree}
tree.Elements = append(tree.Elements, &n.Element)
for i := range node.Children {
n.Children = append(n.Children, walkNode(&node.Children[i], tree))
@@ -333,69 +334,50 @@ func (t *Tree) FindAllBySelectorPath(path []Selector) []*Node {
return t.Root.FindAllBySelectorPath(path)
}
// Find returns the first Node in this node's subtree (descendants only) matching
// the string selector. Path queries within the selector are not supported here.
// Find returns the first Node scoped to this node (descendants, with spatial
// fallback) matching the string selector. Path queries within the selector are
// not supported here.
func (n *Node) Find(selector string) *Node {
kind, value, ok := parseSelector(selector)
if !ok {
return nil
}
for _, child := range n.Children {
if nodes := searchSubtree(child, kind, value); len(nodes) > 0 {
return nodes[0]
}
}
return nil
return firstNode(n.FindAll(selector))
}
// FindAll returns all Nodes in this node's subtree (descendants only) matching
// the string selector.
// FindAll returns all Nodes scoped to this node (descendants, with spatial
// fallback) matching the string selector.
func (n *Node) FindAll(selector string) []*Node {
kind, value, ok := parseSelector(selector)
if !ok {
return nil
}
var result []*Node
for _, child := range n.Children {
result = append(result, searchSubtree(child, kind, value)...)
}
return result
return n.scopedNodes(func(element *Element) bool {
return match(element, kind, value)
})
}
// FindBySelector returns the first Node in this node's subtree matching sel (AND semantics).
// FindBySelector returns the first Node scoped to this node matching sel (AND semantics).
func (n *Node) FindBySelector(sel Selector) *Node {
for _, child := range n.Children {
if nodes := searchSubtreeBySelector(child, sel); len(nodes) > 0 {
return nodes[0]
}
}
return nil
return firstNode(n.FindAllBySelector(sel))
}
// FindAllBySelector returns all Nodes in this node's subtree matching sel (AND semantics).
// FindAllBySelector returns all Nodes scoped to this node matching sel (AND semantics).
func (n *Node) FindAllBySelector(sel Selector) []*Node {
var result []*Node
for _, child := range n.Children {
result = append(result, searchSubtreeBySelector(child, sel)...)
}
return result
return n.scopedNodes(func(element *Element) bool {
return matchSelector(element, sel)
})
}
// FindBySelectorPath walks a chain of selectors. The first selector is matched
// against descendants of the receiver; each subsequent selector is matched
// against descendants of the previous match. Returns the deepest match or nil.
// in the receiver's scope; each subsequent selector is matched in the scope of
// the previous match. Returns the deepest match or nil.
func (n *Node) FindBySelectorPath(path []Selector) *Node {
if len(path) == 0 {
return nil
}
for _, child := range n.Children {
for _, candidate := range searchSubtreeBySelector(child, path[0]) {
if len(path) == 1 {
return candidate
}
if deeper := candidate.FindBySelectorPath(path[1:]); deeper != nil {
return deeper
}
for _, candidate := range n.FindAllBySelector(path[0]) {
if len(path) == 1 {
return candidate
}
if deeper := candidate.FindBySelectorPath(path[1:]); deeper != nil {
return deeper
}
}
return nil
@@ -407,19 +389,86 @@ func (n *Node) FindAllBySelectorPath(path []Selector) []*Node {
if len(path) == 0 {
return nil
}
var result []*Node
for _, candidate := range n.FindAllBySelector(path[0]) {
if len(path) == 1 {
result = append(result, candidate)
continue
}
result = append(result, candidate.FindAllBySelectorPath(path[1:])...)
}
return result
}
func firstNode(nodes []*Node) *Node {
if len(nodes) == 0 {
return nil
}
return nodes[0]
}
// scopedNodes returns this node's descendants matching accept, in pre-order.
// When no descendant matches, nodes spatially contained in this node's bounds
// are matched instead. Compose on iOS emits a testTag node as an empty leaf
// sibling of the content it labels rather than as an ancestor, so descendant
// search under the tagged node finds nothing; bounds containment recovers the
// intended scope.
func (n *Node) scopedNodes(accept func(*Element) bool) []*Node {
var result []*Node
for _, child := range n.Children {
for _, candidate := range searchSubtreeBySelector(child, path[0]) {
if len(path) == 1 {
result = append(result, candidate)
continue
}
result = append(result, candidate.FindAllBySelectorPath(path[1:])...)
collectMatches(child, accept, &result)
}
if len(result) > 0 {
return result
}
for _, candidate := range n.spatialScope() {
if accept(&candidate.Element) {
result = append(result, candidate)
}
}
return result
}
func collectMatches(node *Node, accept func(*Element) bool, result *[]*Node) {
if accept(&node.Element) {
*result = append(*result, node)
}
for _, child := range node.Children {
collectMatches(child, accept, result)
}
}
// spatialScope returns every node in the tree, in pre-order, whose positive
// bounds lie fully inside this node's bounds, excluding the node itself.
func (n *Node) spatialScope() []*Node {
if n.tree == nil || n.tree.Root == nil {
return nil
}
if n.Bounds.Width() <= 0 || n.Bounds.Height() <= 0 {
return nil
}
var result []*Node
var walk func(*Node)
walk = func(candidate *Node) {
if candidate != n &&
candidate.Bounds.Width() > 0 && candidate.Bounds.Height() > 0 &&
containsBounds(n.Bounds, candidate.Bounds) {
result = append(result, candidate)
}
for _, child := range candidate.Children {
walk(child)
}
}
walk(n.tree.Root)
return result
}
// containsBounds reports whether outer fully contains inner (inclusive).
func containsBounds(outer, inner Bounds) bool {
return inner.Left >= outer.Left && inner.Top >= outer.Top &&
inner.Right <= outer.Right && inner.Bottom <= outer.Bottom
}
func findPathNode(root *Node, segments []string) *Node {
if root == nil || len(segments) == 0 {
return nil
@@ -440,18 +489,12 @@ func findPathNode(root *Node, segments []string) *Node {
}
func findPathDescendantsNode(root *Node, segments []string) *Node {
kind, value, ok := parseSelector(segments[0])
if !ok {
return nil
}
for _, child := range root.Children {
for _, node := range searchSubtree(child, kind, value) {
if len(segments) == 1 {
return node
}
if result := findPathDescendantsNode(node, segments[1:]); result != nil {
return result
}
for _, node := range root.FindAll(segments[0]) {
if len(segments) == 1 {
return node
}
if result := findPathDescendantsNode(node, segments[1:]); result != nil {
return result
}
}
return nil
@@ -477,19 +520,13 @@ func findPathAllNodes(root *Node, segments []string) []*Node {
}
func findPathAllDescendantsNodes(root *Node, segments []string) []*Node {
kind, value, ok := parseSelector(segments[0])
if !ok {
return nil
}
var result []*Node
for _, child := range root.Children {
for _, node := range searchSubtree(child, kind, value) {
if len(segments) == 1 {
result = append(result, node)
continue
}
result = append(result, findPathAllDescendantsNodes(node, segments[1:])...)
for _, node := range root.FindAll(segments[0]) {
if len(segments) == 1 {
result = append(result, node)
continue
}
result = append(result, findPathAllDescendantsNodes(node, segments[1:])...)
}
return result
}
+142
View File
@@ -730,3 +730,145 @@ func TestExplicitPackageAttributeWins(t *testing.T) {
t.Errorf("package = %q, want app.folio (explicit attr should win)", got)
}
}
// iosFlatDump mirrors the Compose-on-iOS accessibility shape: the testTag node
// surfaces as an empty leaf SIBLING of the container holding the content it
// labels, with equal bounds, instead of as an ancestor.
const iosFlatDump = `{
"attributes": {"bounds": "[0,0][402,874]"},
"children": [
{
"attributes": {"accessibilityText": "Folio", "bounds": "[0,0][402,874]"},
"children": [
{
"attributes": {"resource-id": "LoginScreen", "bounds": "[0,62][402,840]"},
"children": []
},
{
"attributes": {"bounds": "[0,62][402,840]"},
"children": [
{
"attributes": {"accessibilityText": "EMAIL", "bounds": "[20,106][60,120]"},
"children": []
},
{
"attributes": {"resource-id": "LoginEmail", "accessibilityText": "Email", "bounds": "[34,125][368,173]"},
"children": [
{"attributes": {"bounds": "[34,125][368,173]"}, "children": []}
]
},
{
"attributes": {"resource-id": "LoginPassword", "accessibilityText": "Password", "bounds": "[34,205][368,253]"},
"children": []
},
{
"attributes": {"text": "Sign in", "bounds": "[20,297][382,345]"},
"children": []
},
{
"attributes": {"resource-id": "LoginSubmit", "accessibilityText": "Sign in", "bounds": "[20,297][382,345]"},
"children": [
{"attributes": {"text": "Sign in", "resource-id": "SubmitLabel", "bounds": "[168,312][233,330]"}, "children": []}
]
}
]
}
]
},
{
"attributes": {"bounds": "[0,0][402,54]"},
"children": [
{
"attributes": {"resource-id": "StatusClock", "accessibilityText": "3:24 PM", "bounds": "[55,22][92,42]"},
"children": []
}
]
}
]
}`
func TestIOSFlatSelectorPathFallsBackToBounds(t *testing.T) {
tree, err := Parse(iosFlatDump)
if err != nil {
t.Fatalf("Parse: %v", err)
}
path := []Selector{
{Filters: []AttrFilter{{Attr: "testTag", Value: "LoginScreen"}}},
{Filters: []AttrFilter{{Attr: "testTag", Value: "LoginEmail"}}},
}
node := tree.FindBySelectorPath(path)
if node == nil {
t.Fatal("expected LoginEmail via bounds containment under leaf LoginScreen")
}
if node.ResourceID != "LoginEmail" {
t.Fatalf("got %q, want LoginEmail", node.ResourceID)
}
}
func TestIOSFlatStringPathFallsBackToBounds(t *testing.T) {
tree, _ := Parse(iosFlatDump)
element := tree.Find("id:LoginScreen > id:LoginSubmit")
if element == nil {
t.Fatal("expected LoginSubmit via bounds containment under leaf LoginScreen")
}
if element.ResourceID != "LoginSubmit" {
t.Fatalf("got %q, want LoginSubmit", element.ResourceID)
}
}
func TestIOSFlatScopedNodeFindFallsBackToBounds(t *testing.T) {
tree, _ := Parse(iosFlatDump)
screen := tree.FindNode("id:LoginScreen")
if screen == nil {
t.Fatal("expected LoginScreen node")
}
node := screen.Find("id:LoginPassword")
if node == nil {
t.Fatal("expected LoginPassword via bounds containment")
}
if node.ResourceID != "LoginPassword" {
t.Fatalf("got %q, want LoginPassword", node.ResourceID)
}
}
func TestIOSFlatFindAllBySelectorPathReturnsEachField(t *testing.T) {
tree, _ := Parse(iosFlatDump)
path := []Selector{
{Filters: []AttrFilter{{Attr: "testTag", Value: "LoginScreen"}}},
{Filters: []AttrFilter{{Attr: "label", Value: "word"}}},
}
// label "word" substring-matches "Password" only.
nodes := tree.FindAllBySelectorPath(path)
if len(nodes) != 1 {
t.Fatalf("got %d nodes, want 1", len(nodes))
}
if nodes[0].ResourceID != "LoginPassword" {
t.Fatalf("got %q, want LoginPassword", nodes[0].ResourceID)
}
}
func TestIOSFlatSpatialScopeExcludesOutsideBounds(t *testing.T) {
tree, _ := Parse(iosFlatDump)
// StatusClock sits in the status bar above LoginScreen's bounds; the
// spatial fallback must not leak it into the screen's scope.
if tree.Find("id:LoginScreen > id:StatusClock") != nil {
t.Fatal("StatusClock is outside LoginScreen bounds, expected nil")
}
}
func TestIOSFlatStructuralChildStillPreferred(t *testing.T) {
tree, _ := Parse(iosFlatDump)
submit := tree.FindNode("id:LoginSubmit")
if submit == nil {
t.Fatal("expected LoginSubmit node")
}
// "Sign in" exists as the structural child of LoginSubmit and as an
// equal-bounds sibling decoration; the structural child must win.
node := submit.Find("text:Sign in")
if node == nil {
t.Fatal("expected structural child match")
}
if node.ResourceID != "SubmitLabel" {
t.Fatalf("expected the structural child SubmitLabel, got id=%q", node.ResourceID)
}
}
+23
View File
@@ -497,6 +497,15 @@ func applyAction(ctx context.Context, drv driver.DeviceDriver, action verifier.A
return err
}
}
// InputText replaces the field's content: erase what the target
// holds before typing. Appending instead lets repeated draws grow
// the field without bound (e.g. into a max-length validation error
// the fuzzer can never escape) and makes retried typing land twice.
if count := existingTextLength(action, tree); count > 0 {
if err := drv.EraseText(ctx, count); err != nil {
return err
}
}
return drv.InputText(ctx, action.Text)
case verifier.ActionKindSwipe:
duration := time.Duration(action.DurationMillis) * time.Millisecond
@@ -547,6 +556,20 @@ func collectLogs(ctx context.Context, drv driver.DeviceDriver, since time.Time)
return result
}
// existingTextLength returns the character count of the InputText target's
// current text, so the runner can erase it before typing. Zero when the
// target cannot be resolved or holds no text.
func existingTextLength(action verifier.Action, tree *hierarchy.Tree) int {
if action.On == "" || tree == nil {
return 0
}
element := tree.Find(action.On)
if element == nil {
return 0
}
return len([]rune(element.Text))
}
func resolveCoordinates(action verifier.Action, tree *hierarchy.Tree) (int, int, bool) {
// When On is empty, X/Y are authoritative (web V8 path emits coordinates
// directly from getBoundingClientRect; the runtime nullifies unresolved
+46
View File
@@ -550,6 +550,52 @@ func TestRunner_LogsWaitForIdleDriverErrors(t *testing.T) {
}
}
func TestApplyAction_InputTextErasesExistingTextBeforeTyping(t *testing.T) {
tree, err := hierarchy.Parse(`{"attributes":{"resource-id":"root","bounds":"[0,0,1080,2340]"},"children":[
{"attributes":{"resource-id":"username","text":"stale-value","bounds":"[10,10,500,100]"},"children":[]}
]}`)
if err != nil {
t.Fatalf("Parse: %v", err)
}
driverMock := mockdriver.New()
action := verifier.Action{Kind: verifier.ActionKindInputText, On: "id:username", Text: "alice"}
if err := applyAction(context.Background(), driverMock, action, tree); err != nil {
t.Fatalf("applyAction: %v", err)
}
actions := driverMock.Actions()
if len(actions) != 3 {
t.Fatalf("want tap, erase, input; got %v", actions)
}
if actions[0].Kind != mockdriver.ActionTap {
t.Errorf("first action = %v, want tap", actions[0].Kind)
}
if actions[1].Kind != mockdriver.ActionEraseText || actions[1].CharacterCount != len("stale-value") {
t.Errorf("second action = %+v, want erase_text of %d characters", actions[1], len("stale-value"))
}
if actions[2].Kind != mockdriver.ActionInputText || actions[2].Text != "alice" {
t.Errorf("third action = %+v, want input_text alice", actions[2])
}
}
func TestApplyAction_InputTextSkipsEraseWhenTargetEmpty(t *testing.T) {
tree, err := hierarchy.Parse(`{"attributes":{"resource-id":"root","bounds":"[0,0,1080,2340]"},"children":[
{"attributes":{"resource-id":"username","bounds":"[10,10,500,100]"},"children":[]}
]}`)
if err != nil {
t.Fatalf("Parse: %v", err)
}
driverMock := mockdriver.New()
action := verifier.Action{Kind: verifier.ActionKindInputText, On: "id:username", Text: "alice"}
if err := applyAction(context.Background(), driverMock, action, tree); err != nil {
t.Fatalf("applyAction: %v", err)
}
if containsAction(driverMock.Actions(), mockdriver.ActionEraseText, "") {
t.Errorf("empty field must not be erased: %v", driverMock.Actions())
}
}
func TestApplyAction_InputTextSurfacesFocusTapError(t *testing.T) {
t.Run("selector focus tap fails", func(t *testing.T) {
driverMock := mockdriver.New()