diff --git a/pkg/driver/browser/cdp/commands.go b/pkg/driver/browser/cdp/commands.go index 1fb9d6c..7bbad27 100644 --- a/pkg/driver/browser/cdp/commands.go +++ b/pkg/driver/browser/cdp/commands.go @@ -155,29 +155,55 @@ func (d *Driver) dispatchCrossRoot(elem *rod.Element, info *core.ElementInfo, de return errorResult(err, fmt.Sprintf("Failed to dispatch input for %s", desc)) } - // Step 4: poll hit-target verify. + // Step 4: poll for verify result. Chromium delivers trusted events + // synchronously during Mouse.Click, so the first poll is usually + // decisive. Brief retry window absorbs scheduler jitter on slower + // machines / CI. + // + // pollHitTargetResult always returns an object with a `status` field: + // { status: 'done' } + // { status: 'pending', inFlutter: bool } + // { status: 'failed', hitTargetDescription: string } + inFlutter := false for i := 0; i < 5; i++ { pollRes, pollErr := d.page.Eval(`(t) => window.__maestro.pollHitTargetResult(t)`, token) if pollErr != nil { return errorResult(pollErr, fmt.Sprintf("Failed to poll hit-target result for %s", desc)) } v := pollRes.Value - if v.Has("hitTargetDescription") { - hd := v.Get("hitTargetDescription").Str() - return errorResult( - fmt.Errorf("input did not reach %s — landed on %s", desc, hd), - fmt.Sprintf("Input on %s did not reach the target (landed on %s)", desc, hd)) - } - switch v.Str() { + switch v.Get("status").Str() { case "pending": + if v.Get("inFlutter").Bool() { + inFlutter = true + } time.Sleep(20 * time.Millisecond) continue case "done": return successResult(fmt.Sprintf("%s on %s", verbed, desc), info) - default: - return successResult(fmt.Sprintf("%s on %s", verbed, desc), info) + case "failed": + hd := v.Get("hitTargetDescription").Str() + return errorResult( + fmt.Errorf("input did not reach %s — landed on %s", desc, hd), + fmt.Sprintf("Input on %s did not reach the target (landed on %s)", desc, hd)) } } + // Flutter Web concession (post-click): the trusted event verifier never + // captured a pointerdown/mousedown on the target frame's window. For + // Flutter targets this is the expected steady state — Flutter's pointer + // router sits at the document/flutter-view capture layer and routes the + // trusted event to its own internal hit testing for semantics dispatch; + // it generally does not bubble back out as a window-level + // pointerdown/mousedown that a third-party listener can observe. Pre- + // flight expectHitTarget already validated the static hit point and + // applied the same Flutter concession (jshelper.js:expectHitTarget). So + // when we got past pre-flight and the target lives in Flutter, accept + // the dispatch — Chromium delivered a trusted click at the target's + // coordinates and Flutter handled it. Living in dispatchCrossRoot means + // doubleTapOn / longPressOn / scrollUntilVisible inherit the concession + // for free, since they share this dispatch path. + if inFlutter { + return successResult(fmt.Sprintf("%s on %s", verbed, desc), info) + } return errorResult( fmt.Errorf("hit-target verify did not capture trusted event within timeout"), fmt.Sprintf("Input on %s dispatched but verification timed out", desc)) diff --git a/pkg/driver/browser/cdp/finder.go b/pkg/driver/browser/cdp/finder.go index 76330d8..2cf1ceb 100644 --- a/pkg/driver/browser/cdp/finder.go +++ b/pkg/driver/browser/cdp/finder.go @@ -511,14 +511,19 @@ func (d *Driver) findByAXTree(text, role string, sel flow.Selector) (*rod.Elemen // findBySearch uses Rod's page.Search() which handles Shadow DOM via DOMPerformSearch. // -// Reject IFRAME results: CDP DOM.performSearch matches against the iframe -// element's serialized attributes, including srcdoc — for an iframe whose -// srcdoc HTML happens to contain the search text, the iframe element itself -// is returned. That is essentially never what the caller wants (the caller -// is looking for a tappable text element, not the iframe wrapper). Falling -// through here lets the cascade reach the JS findByText path, which walks -// _collectRoots() into the iframe document and shadow DOM and returns the -// actual visible match. (Issues #71/#72 acting layer.) +// Reject non-tappable text containers: CDP DOM.performSearch matches against +// the serialized HTML of every node, including the source text of