mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
fix(chrome): scroll a gesture point into view and dispatch trusted input
getBoundingClientRect keeps reporting elements the growing document pushed below the emulated viewport, and input coordinates are viewport-relative, so a click below the fold was hit-tested to the document root and the step read as an action that landed. every gesture now scrolls the point back in and reports ErrGestureUndelivered when nothing is under it; a selector that names no node reports ErrSelectorMatchedNothing rather than waiting. swipe dispatches a real touch stream instead of page-synthesized pointer events, scroll is a wheel so its distance is exact rather than a fling, and the second tap of a double tap carries click count 2 so dblclick actually fires.
This commit is contained in:
1 parent
05c8523762
commit
45dd52c5d9
3 files changed
+661
-26
No files matched your search
@@ -11,6 +11,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/chromedp/cdproto/cdp"
|
||||||
"github.com/chromedp/cdproto/input"
|
"github.com/chromedp/cdproto/input"
|
||||||
"github.com/chromedp/cdproto/network"
|
"github.com/chromedp/cdproto/network"
|
||||||
"github.com/chromedp/cdproto/page"
|
"github.com/chromedp/cdproto/page"
|
||||||
@@ -201,14 +202,86 @@ func (d *Driver) Terminate(_ context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pointInViewScript scrolls a point the caller took from the hierarchy back
|
||||||
|
// inside the viewport and reports where to dispatch at, plus whether anything
|
||||||
|
// is there to receive it.
|
||||||
|
//
|
||||||
|
// The emulated viewport is sized once at launch, but getBoundingClientRect goes
|
||||||
|
// on reporting elements the growing document has pushed below it, so the two
|
||||||
|
// disagree the moment an app adds content. Input coordinates are
|
||||||
|
// viewport-relative: a click below the fold is hit-tested to the document root,
|
||||||
|
// which delivers it to <html> and never to the element the caller named. No
|
||||||
|
// error is raised on any layer, so the step reads as an action that landed and
|
||||||
|
// changed nothing.
|
||||||
|
//
|
||||||
|
// Only a point outside the viewport is moved, so a gesture that already had a
|
||||||
|
// reachable target dispatches exactly where it did before.
|
||||||
|
const pointInViewScript = `
|
||||||
|
(function(x, y) {
|
||||||
|
const root = document.scrollingElement || document.documentElement;
|
||||||
|
let shiftX = 0, shiftY = 0;
|
||||||
|
if (x < 0 || x >= window.innerWidth) shiftX = Math.round(x - window.innerWidth / 2);
|
||||||
|
if (y < 0 || y >= window.innerHeight) shiftY = Math.round(y - window.innerHeight / 2);
|
||||||
|
if (shiftX || shiftY) {
|
||||||
|
const fromX = root.scrollLeft, fromY = root.scrollTop;
|
||||||
|
root.scrollLeft = fromX + shiftX;
|
||||||
|
root.scrollTop = fromY + shiftY;
|
||||||
|
shiftX = root.scrollLeft - fromX;
|
||||||
|
shiftY = root.scrollTop - fromY;
|
||||||
|
}
|
||||||
|
const atX = x - shiftX, atY = y - shiftY;
|
||||||
|
return [atX, atY, document.elementFromPoint(atX, atY) ? 1 : 0];
|
||||||
|
})(%d, %d)`
|
||||||
|
|
||||||
|
// pointInView returns the point to dispatch a gesture at for the point the
|
||||||
|
// caller named, having scrolled it into view. It fails with
|
||||||
|
// driver.ErrGestureUndelivered when no scroll can put an element under it.
|
||||||
|
func pointInView(runCtx context.Context, x, y int) (int, int, error) {
|
||||||
|
var point [3]int
|
||||||
|
script := fmt.Sprintf(pointInViewScript, x, y)
|
||||||
|
if err := chromedp.Run(runCtx, chromedp.Evaluate(script, &point)); err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
if point[2] == 0 {
|
||||||
|
return 0, 0, fmt.Errorf(
|
||||||
|
"%w: (%d,%d)",
|
||||||
|
driver.ErrGestureUndelivered,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return point[0], point[1], nil
|
||||||
|
}
|
||||||
|
|
||||||
func (d *Driver) Tap(ctx context.Context, x, y int) error {
|
func (d *Driver) Tap(ctx context.Context, x, y int) error {
|
||||||
runCtx, cancel := d.runCtx(ctx)
|
runCtx, cancel := d.runCtx(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
atX, atY, err := pointInView(runCtx, x, y)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return chromedp.Run(runCtx,
|
return chromedp.Run(runCtx,
|
||||||
chromedp.MouseClickXY(float64(x), float64(y)),
|
chromedp.MouseClickXY(float64(atX), float64(atY)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requireSelectorMatch reports driver.ErrSelectorMatchedNothing when the
|
||||||
|
// selector names no node on the page right now. chromedp.Click waits instead,
|
||||||
|
// so without this the caller hears a deadline (or nothing at all) for an action
|
||||||
|
// that had no target.
|
||||||
|
func requireSelectorMatch(runCtx context.Context, target, selector string) error {
|
||||||
|
var nodes []*cdp.Node
|
||||||
|
if err := chromedp.Run(runCtx,
|
||||||
|
chromedp.Nodes(target, &nodes, chromedp.BySearch, chromedp.AtLeast(0)),
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(nodes) == 0 {
|
||||||
|
return fmt.Errorf("%w: %q", driver.ErrSelectorMatchedNothing, selector)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (d *Driver) TapSelector(ctx context.Context, selector string) error {
|
func (d *Driver) TapSelector(ctx context.Context, selector string) error {
|
||||||
runCtx, cancel := d.runCtx(ctx)
|
runCtx, cancel := d.runCtx(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -218,6 +291,9 @@ func (d *Driver) TapSelector(ctx context.Context, selector string) error {
|
|||||||
// reject it loudly if it isn't a valid CSS selector.
|
// reject it loudly if it isn't a valid CSS selector.
|
||||||
target = selector
|
target = selector
|
||||||
}
|
}
|
||||||
|
if err := requireSelectorMatch(runCtx, target, selector); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if isXPath {
|
if isXPath {
|
||||||
return chromedp.Run(runCtx, chromedp.Click(target, chromedp.NodeVisible, chromedp.BySearch))
|
return chromedp.Run(runCtx, chromedp.Click(target, chromedp.NodeVisible, chromedp.BySearch))
|
||||||
}
|
}
|
||||||
@@ -229,16 +305,55 @@ func (d *Driver) TapSelector(ctx context.Context, selector string) error {
|
|||||||
// primitive, so the gesture is two taps with this gap.
|
// primitive, so the gesture is two taps with this gap.
|
||||||
const doubleTapGap = 50 * time.Millisecond
|
const doubleTapGap = 50 * time.Millisecond
|
||||||
|
|
||||||
|
// DoubleTap resolves the point once and dispatches both taps there: resolving
|
||||||
|
// per tap would scroll the second one away from the element the first hit.
|
||||||
func (d *Driver) DoubleTap(ctx context.Context, x, y int) error {
|
func (d *Driver) DoubleTap(ctx context.Context, x, y int) error {
|
||||||
return webDoubleTap(ctx, func() error { return d.Tap(ctx, x, y) })
|
runCtx, cancel := d.runCtx(ctx)
|
||||||
|
defer cancel()
|
||||||
|
atX, atY, err := pointInView(runCtx, x, y)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return webDoubleTap(ctx, func(clickCount int) error {
|
||||||
|
return chromedp.Run(
|
||||||
|
runCtx,
|
||||||
|
chromedp.MouseClickXY(
|
||||||
|
float64(atX),
|
||||||
|
float64(atY),
|
||||||
|
chromedp.ClickCount(clickCount),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Driver) DoubleTapSelector(ctx context.Context, selector string) error {
|
func (d *Driver) DoubleTapSelector(ctx context.Context, selector string) error {
|
||||||
return webDoubleTap(ctx, func() error { return d.TapSelector(ctx, selector) })
|
runCtx, cancel := d.runCtx(ctx)
|
||||||
|
defer cancel()
|
||||||
|
target, isXPath, err := TranslateStringSelector(selector)
|
||||||
|
if err != nil {
|
||||||
|
target = selector
|
||||||
|
}
|
||||||
|
if err := requireSelectorMatch(runCtx, target, selector); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
options := []chromedp.QueryOption{chromedp.NodeVisible}
|
||||||
|
if isXPath {
|
||||||
|
options = append(options, chromedp.BySearch)
|
||||||
|
}
|
||||||
|
return webDoubleTap(ctx, func(clickCount int) error {
|
||||||
|
if clickCount < 2 {
|
||||||
|
return chromedp.Run(runCtx, chromedp.Click(target, options...))
|
||||||
|
}
|
||||||
|
return chromedp.Run(runCtx, chromedp.DoubleClick(target, options...))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func webDoubleTap(ctx context.Context, tap func() error) error {
|
// webDoubleTap dispatches the pair a browser reads as one double click. Blink
|
||||||
if err := tap(); err != nil {
|
// raises dblclick off the click count the second event carries, so two taps
|
||||||
|
// that both say "first click" arrive at a dblclick handler as two ordinary
|
||||||
|
// clicks and the gesture never happens at all.
|
||||||
|
func webDoubleTap(ctx context.Context, tap func(clickCount int) error) error {
|
||||||
|
if err := tap(1); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
timer := time.NewTimer(doubleTapGap)
|
timer := time.NewTimer(doubleTapGap)
|
||||||
@@ -248,7 +363,7 @@ func webDoubleTap(ctx context.Context, tap func() error) error {
|
|||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
case <-timer.C:
|
case <-timer.C:
|
||||||
}
|
}
|
||||||
return tap()
|
return tap(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Driver) InputText(callerCtx context.Context, text string) error {
|
func (d *Driver) InputText(callerCtx context.Context, text string) error {
|
||||||
@@ -307,31 +422,69 @@ func (d *Driver) EraseText(callerCtx context.Context, _ int) error {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Swipe drags a finger across the page as a trusted touch stream. Events
|
||||||
|
// synthesized in the page carry isTrusted false: they reach a handler that
|
||||||
|
// happens to listen, but never enter the input pipeline that scrolls, honours
|
||||||
|
// touch-action or resolves a gesture.
|
||||||
func (d *Driver) Swipe(ctx context.Context, fromX, fromY, toX, toY int, duration time.Duration) error {
|
func (d *Driver) Swipe(ctx context.Context, fromX, fromY, toX, toY int, duration time.Duration) error {
|
||||||
runCtx, cancel := d.runCtx(ctx)
|
runCtx, cancel := d.runCtx(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
millis := max(duration.Milliseconds(), 50)
|
atX, atY, err := pointInView(runCtx, fromX, fromY)
|
||||||
script := fmt.Sprintf(`
|
if err != nil {
|
||||||
(function() {
|
return err
|
||||||
const el = document.elementFromPoint(%d, %d);
|
|
||||||
if (!el) return;
|
|
||||||
const steps = Math.max(1, Math.floor(%d / 16));
|
|
||||||
const dx = (%d - %d) / steps;
|
|
||||||
const dy = (%d - %d) / steps;
|
|
||||||
el.dispatchEvent(new PointerEvent('pointerdown', {clientX: %d, clientY: %d, bubbles: true}));
|
|
||||||
for (let i = 1; i <= steps; i++) {
|
|
||||||
el.dispatchEvent(new PointerEvent('pointermove', {clientX: %d + dx*i, clientY: %d + dy*i, bubbles: true}));
|
|
||||||
}
|
}
|
||||||
el.dispatchEvent(new PointerEvent('pointerup', {clientX: %d, clientY: %d, bubbles: true}));
|
toX, toY = toX-(fromX-atX), toY-(fromY-atY)
|
||||||
})();`,
|
steps := max(int(duration.Milliseconds())/16, 1)
|
||||||
fromX, fromY,
|
actions := []chromedp.Action{touchAt(input.TouchStart, atX, atY)}
|
||||||
millis,
|
for i := 1; i <= steps; i++ {
|
||||||
toX, fromX, toY, fromY,
|
actions = append(actions, touchAt(input.TouchMove,
|
||||||
fromX, fromY,
|
atX+(toX-atX)*i/steps, atY+(toY-atY)*i/steps))
|
||||||
fromX, fromY,
|
}
|
||||||
toX, toY,
|
actions = append(
|
||||||
|
actions,
|
||||||
|
input.DispatchTouchEvent(input.TouchEnd, []*input.TouchPoint{}),
|
||||||
)
|
)
|
||||||
return chromedp.Run(runCtx, chromedp.Evaluate(script, nil))
|
return chromedp.Run(runCtx, actions...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func touchAt(kind input.TouchType, x, y int) *input.DispatchTouchEventParams {
|
||||||
|
return input.DispatchTouchEvent(
|
||||||
|
kind,
|
||||||
|
[]*input.TouchPoint{{X: float64(x), Y: float64(y)}},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll moves the content under the point with a trusted wheel, which is how a
|
||||||
|
// browser scrolls.
|
||||||
|
//
|
||||||
|
// A finger drag scrolls too, but it ends in a fling whose distance follows the
|
||||||
|
// release velocity: five identical 240 px drags moved the page 354 to 616 px,
|
||||||
|
// so two runs of one seed would explore different screens. A wheel delta lands
|
||||||
|
// exactly, and chains from the element under the point out to its scrollable
|
||||||
|
// ancestors, which is what scrolling a named container means. The drag stays as
|
||||||
|
// Swipe, the verb for the gestures only a finger reaches.
|
||||||
|
func (d *Driver) Scroll(
|
||||||
|
ctx context.Context,
|
||||||
|
fromX, fromY, toX, toY int,
|
||||||
|
_ time.Duration,
|
||||||
|
) error {
|
||||||
|
runCtx, cancel := d.runCtx(ctx)
|
||||||
|
defer cancel()
|
||||||
|
atX, atY, err := pointInView(runCtx, fromX, fromY)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
wheel := input.DispatchMouseEvent(input.MouseWheel, float64(atX), float64(atY)).
|
||||||
|
WithDeltaX(float64(fromX - toX)).
|
||||||
|
WithDeltaY(float64(fromY - toY))
|
||||||
|
// The wheel is applied off the CDP round trip, so without this the next
|
||||||
|
// read races it: a step could observe the page before its own scroll, and
|
||||||
|
// the pending scroll then lands during the following one.
|
||||||
|
return chromedp.Run(runCtx, wheel, chromedp.Evaluate(
|
||||||
|
`new Promise(done => requestAnimationFrame(() => requestAnimationFrame(done)))`,
|
||||||
|
nil,
|
||||||
|
awaitPromise,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Driver) PressKey(ctx context.Context, key string) error {
|
func (d *Driver) PressKey(ctx context.Context, key string) error {
|
||||||
@@ -347,6 +500,10 @@ func (d *Driver) PressKey(ctx context.Context, key string) error {
|
|||||||
func (d *Driver) LongPress(ctx context.Context, x, y int) error {
|
func (d *Driver) LongPress(ctx context.Context, x, y int) error {
|
||||||
runCtx, cancel := d.runCtx(ctx)
|
runCtx, cancel := d.runCtx(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
x, y, err := pointInView(runCtx, x, y)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
(function() {
|
(function() {
|
||||||
const el = document.elementFromPoint(%d, %d);
|
const el = document.elementFromPoint(%d, %d);
|
||||||
|
|||||||
@@ -9,11 +9,14 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/chromedp/chromedp"
|
"github.com/chromedp/chromedp"
|
||||||
|
"github.com/priyanshujain/sanderling/internal/driver"
|
||||||
|
"github.com/priyanshujain/sanderling/internal/hierarchy"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestLaunch_ClearStateWipesStorageForTheTargetOrigin covers the CLI's default
|
// TestLaunch_ClearStateWipesStorageForTheTargetOrigin covers the CLI's default
|
||||||
@@ -1084,3 +1087,426 @@ func TestHierarchy_CarriesEveryMarkupAttribute(t *testing.T) {
|
|||||||
t.Errorf(`attrs["data-testid"] = %q, want "account-card"`, got)
|
t.Errorf(`attrs["data-testid"] = %q, want "account-card"`, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// growingPage serves a page whose content starts shorter than one screen and
|
||||||
|
// grows past it when its first button is tapped, which is what a chat, feed or
|
||||||
|
// ledger does as a run drives it.
|
||||||
|
const growingPage = `<body style="margin:0">
|
||||||
|
<button id="grow" style="height:80px">grow</button>
|
||||||
|
<div id="status">idle</div>
|
||||||
|
<div id="rest"></div>
|
||||||
|
<script>
|
||||||
|
document.getElementById('grow').addEventListener('click', function() {
|
||||||
|
document.getElementById('rest').innerHTML =
|
||||||
|
'<div style="height:900px"></div>' +
|
||||||
|
'<button id="below" style="height:60px">below</button>';
|
||||||
|
document.getElementById('below').addEventListener('click', function() {
|
||||||
|
document.getElementById('status').textContent = 'below tapped';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script></body>`
|
||||||
|
|
||||||
|
// TestTap_ActuatesAnElementBelowTheLaunchViewport pins the invariant the whole
|
||||||
|
// web path rests on: an element the driver reports as present and clickable can
|
||||||
|
// be acted on. The emulated viewport is sized once at launch, so content the
|
||||||
|
// app adds afterwards lies below it while getBoundingClientRect keeps reporting
|
||||||
|
// where it is; a click dispatched there is hit-tested to the document root and
|
||||||
|
// the element never sees it, with no error anywhere.
|
||||||
|
func TestTap_ActuatesAnElementBelowTheLaunchViewport(t *testing.T) {
|
||||||
|
server := httptest.NewServer(
|
||||||
|
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
_, _ = w.Write([]byte(growingPage))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
d := New()
|
||||||
|
defer d.Terminate(context.Background())
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := d.Launch(ctx, server.URL, false, nil); err != nil {
|
||||||
|
t.Fatalf("Launch: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tapByID := func(id string) {
|
||||||
|
t.Helper()
|
||||||
|
dump, err := d.Hierarchy(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Hierarchy: %v", err)
|
||||||
|
}
|
||||||
|
tree, err := hierarchy.Parse(dump)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse: %v", err)
|
||||||
|
}
|
||||||
|
element := tree.Find("id:" + id)
|
||||||
|
if element == nil {
|
||||||
|
t.Fatalf("%s is not in the dump", id)
|
||||||
|
}
|
||||||
|
if !element.Clickable {
|
||||||
|
t.Fatalf("%s is not reported clickable", id)
|
||||||
|
}
|
||||||
|
x, y := element.Bounds.Center()
|
||||||
|
if err := d.Tap(ctx, x, y); err != nil {
|
||||||
|
t.Fatalf("Tap %s at (%d,%d): %v", id, x, y, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tapByID("grow")
|
||||||
|
tapByID("below")
|
||||||
|
|
||||||
|
dump, err := d.Hierarchy(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Hierarchy: %v", err)
|
||||||
|
}
|
||||||
|
tree, err := hierarchy.Parse(dump)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse: %v", err)
|
||||||
|
}
|
||||||
|
status := tree.Find("id:status")
|
||||||
|
if status == nil {
|
||||||
|
t.Fatal("status is not in the dump")
|
||||||
|
}
|
||||||
|
if status.Text != "below tapped" {
|
||||||
|
t.Errorf(
|
||||||
|
"status = %q, want %q: the tap reached no element",
|
||||||
|
status.Text,
|
||||||
|
"below tapped",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTap_ReportsAGestureThatReachesNoElement covers the half of the same bug
|
||||||
|
// that no scrolling can fix: a page that cannot scroll leaves the point out of
|
||||||
|
// reach, and the caller has to hear about it rather than read a clean run.
|
||||||
|
func TestTap_ReportsAGestureThatReachesNoElement(t *testing.T) {
|
||||||
|
const page = `<body style="margin:0;overflow:hidden">
|
||||||
|
<div style="height:80px">top</div>
|
||||||
|
<div id="rest" style="height:900px;overflow:hidden"></div>
|
||||||
|
<style>html{overflow:hidden}</style></body>`
|
||||||
|
server := httptest.NewServer(
|
||||||
|
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
_, _ = w.Write([]byte(page))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
d := New()
|
||||||
|
defer d.Terminate(context.Background())
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := d.Launch(ctx, server.URL, false, nil); err != nil {
|
||||||
|
t.Fatalf("Launch: %v", err)
|
||||||
|
}
|
||||||
|
err := d.Tap(ctx, 100, 5000)
|
||||||
|
if !errors.Is(err, driver.ErrGestureUndelivered) {
|
||||||
|
t.Fatalf(
|
||||||
|
"Tap far below an unscrollable page: err = %v, want ErrGestureUndelivered",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTapSelector_ReportsASelectorThatMatchesNothing covers the by-selector
|
||||||
|
// half of a step that reads as dispatched and did nothing: the node the
|
||||||
|
// selector names is not on the page, so the click has no target at all.
|
||||||
|
func TestTapSelector_ReportsASelectorThatMatchesNothing(t *testing.T) {
|
||||||
|
const page = `<body style="margin:0">
|
||||||
|
<button id="present">here</button>
|
||||||
|
<div id="status">none</div>
|
||||||
|
<script>
|
||||||
|
document.getElementById('present').addEventListener('click', function () {
|
||||||
|
document.getElementById('status').textContent = 'present tapped';
|
||||||
|
});
|
||||||
|
</script></body>`
|
||||||
|
server := httptest.NewServer(
|
||||||
|
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
_, _ = w.Write([]byte(page))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
d := New()
|
||||||
|
defer d.Terminate(context.Background())
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := d.Launch(ctx, server.URL, false, nil); err != nil {
|
||||||
|
t.Fatalf("Launch: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
missCtx, missCancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer missCancel()
|
||||||
|
if err := d.TapSelector(missCtx, "id:absent"); !errors.Is(err, driver.ErrSelectorMatchedNothing) {
|
||||||
|
t.Fatalf("TapSelector on an absent element: err = %v, want ErrSelectorMatchedNothing", err)
|
||||||
|
}
|
||||||
|
if err := d.DoubleTapSelector(missCtx, "id:absent"); !errors.Is(err, driver.ErrSelectorMatchedNothing) {
|
||||||
|
t.Fatalf("DoubleTapSelector on an absent element: err = %v, want ErrSelectorMatchedNothing", err)
|
||||||
|
}
|
||||||
|
if err := d.TapSelector(ctx, "id:present"); err != nil {
|
||||||
|
t.Fatalf("TapSelector on the element that is there: %v", err)
|
||||||
|
}
|
||||||
|
dump, err := d.Hierarchy(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Hierarchy: %v", err)
|
||||||
|
}
|
||||||
|
tree, err := hierarchy.Parse(dump)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse: %v", err)
|
||||||
|
}
|
||||||
|
if status := tree.Find("id:status"); status == nil || status.Text != "present tapped" {
|
||||||
|
t.Fatalf("status = %+v, want the tap on the present element to have landed", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const doubleClickPage = `<body style="margin:0">
|
||||||
|
<div id="target" style="width:200px;height:60px">edit me</div>
|
||||||
|
<div id="status">none</div>
|
||||||
|
<script>
|
||||||
|
var box = document.getElementById('target');
|
||||||
|
var report = document.getElementById('status');
|
||||||
|
var clicks = 0;
|
||||||
|
box.addEventListener('click', function () { clicks++; });
|
||||||
|
box.addEventListener('dblclick', function () {
|
||||||
|
report.textContent = 'edited after ' + clicks + ' clicks';
|
||||||
|
});
|
||||||
|
</script></body>`
|
||||||
|
|
||||||
|
func doubleClickStatus(t *testing.T, d *Driver, ctx context.Context) string {
|
||||||
|
t.Helper()
|
||||||
|
dump, err := d.Hierarchy(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Hierarchy: %v", err)
|
||||||
|
}
|
||||||
|
tree, err := hierarchy.Parse(dump)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse: %v", err)
|
||||||
|
}
|
||||||
|
status := tree.Find("id:status")
|
||||||
|
if status == nil {
|
||||||
|
t.Fatal("status is not in the dump")
|
||||||
|
}
|
||||||
|
return status.Text
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDoubleTap_ReachesADoubleClickHandler pins a gesture the web driver had no
|
||||||
|
// way to deliver. Blink raises dblclick off the click count the second event
|
||||||
|
// carries, so a pair that both said "first click" arrived as two ordinary
|
||||||
|
// clicks: every double-click affordance on the web (an editable list row, a
|
||||||
|
// canvas, a table cell) was unreachable, with no error on any layer.
|
||||||
|
func TestDoubleTap_ReachesADoubleClickHandler(t *testing.T) {
|
||||||
|
server := httptest.NewServer(
|
||||||
|
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
_, _ = w.Write([]byte(doubleClickPage))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
d := New()
|
||||||
|
defer d.Terminate(context.Background())
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := d.Launch(ctx, server.URL, false, nil); err != nil {
|
||||||
|
t.Fatalf("Launch: %v", err)
|
||||||
|
}
|
||||||
|
if err := d.DoubleTap(ctx, 100, 30); err != nil {
|
||||||
|
t.Fatalf("DoubleTap: %v", err)
|
||||||
|
}
|
||||||
|
if status := doubleClickStatus(t, d, ctx); status != "edited after 2 clicks" {
|
||||||
|
t.Errorf(
|
||||||
|
"status = %q, want %q: the pair never read as one double click",
|
||||||
|
status,
|
||||||
|
"edited after 2 clicks",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDoubleTapSelector_ReachesADoubleClickHandler covers the same gesture on
|
||||||
|
// the path the runner takes when the action names its target rather than a
|
||||||
|
// point.
|
||||||
|
func TestDoubleTapSelector_ReachesADoubleClickHandler(t *testing.T) {
|
||||||
|
server := httptest.NewServer(
|
||||||
|
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
_, _ = w.Write([]byte(doubleClickPage))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
d := New()
|
||||||
|
defer d.Terminate(context.Background())
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := d.Launch(ctx, server.URL, false, nil); err != nil {
|
||||||
|
t.Fatalf("Launch: %v", err)
|
||||||
|
}
|
||||||
|
if err := d.DoubleTapSelector(ctx, "id:target"); err != nil {
|
||||||
|
t.Fatalf("DoubleTapSelector: %v", err)
|
||||||
|
}
|
||||||
|
if status := doubleClickStatus(t, d, ctx); status != "edited after 2 clicks" {
|
||||||
|
t.Errorf(
|
||||||
|
"status = %q, want %q: the pair never read as one double click",
|
||||||
|
status,
|
||||||
|
"edited after 2 clicks",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gesturesServer serves the fixture both gesture tests measure against: a
|
||||||
|
// document taller than the emulated viewport, a scrollable container inside it,
|
||||||
|
// and a row that dismisses on a horizontal drag.
|
||||||
|
func gesturesServer(t *testing.T) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
body, err := os.ReadFile("testdata/gestures.html")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
server := httptest.NewServer(
|
||||||
|
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
_, _ = w.Write(body)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScroll_MovesThePageAndAScrollableContainer covers the verb the runner
|
||||||
|
// lowers every Scroll action onto. Script-dispatched pointer events are
|
||||||
|
// untrusted and a browser never scrolls on them, so the web Scroll used to
|
||||||
|
// leave scrollY and every scrollTop exactly where they were while reporting a
|
||||||
|
// step that ran. The repeat also pins the distance: a run that scrolls a
|
||||||
|
// different amount each time explores differently on the same seed.
|
||||||
|
func TestScroll_MovesThePageAndAScrollableContainer(t *testing.T) {
|
||||||
|
server := gesturesServer(t)
|
||||||
|
d := New()
|
||||||
|
defer d.Terminate(context.Background())
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := d.Launch(ctx, server.URL, false, nil); err != nil {
|
||||||
|
t.Fatalf("Launch: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
read := func(expression string) int {
|
||||||
|
t.Helper()
|
||||||
|
var value int
|
||||||
|
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(expression, &value)); err != nil {
|
||||||
|
t.Fatalf("evaluate %s: %v", expression, err)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
const pageScroll = `Math.round(window.scrollY)`
|
||||||
|
const containerScroll = `Math.round(document.getElementById("inner").scrollTop)`
|
||||||
|
|
||||||
|
if before := read(pageScroll); before != 0 {
|
||||||
|
t.Fatalf("scrollY before = %d, want 0", before)
|
||||||
|
}
|
||||||
|
var pageDistances []int
|
||||||
|
for range 3 {
|
||||||
|
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(`window.scrollTo(0, 0)`, nil)); err != nil {
|
||||||
|
t.Fatalf("reset: %v", err)
|
||||||
|
}
|
||||||
|
if err := d.Scroll(ctx, 195, 500, 195, 260, 300*time.Millisecond); err != nil {
|
||||||
|
t.Fatalf("Scroll: %v", err)
|
||||||
|
}
|
||||||
|
pageDistances = append(pageDistances, read(pageScroll))
|
||||||
|
}
|
||||||
|
if pageDistances[0] <= 0 {
|
||||||
|
t.Errorf(
|
||||||
|
"scrollY after = %d, want > 0: the page never scrolled",
|
||||||
|
pageDistances[0],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if pageDistances[0] != pageDistances[1] ||
|
||||||
|
pageDistances[1] != pageDistances[2] {
|
||||||
|
t.Errorf(
|
||||||
|
"scrollY over three identical scrolls = %v, want one distance",
|
||||||
|
pageDistances,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(`window.scrollTo(0, 0)`, nil)); err != nil {
|
||||||
|
t.Fatalf("reset: %v", err)
|
||||||
|
}
|
||||||
|
containerY := read(
|
||||||
|
`Math.round(document.getElementById("inner").getBoundingClientRect().top + 100)`,
|
||||||
|
)
|
||||||
|
if before := read(containerScroll); before != 0 {
|
||||||
|
t.Fatalf("container scrollTop before = %d, want 0", before)
|
||||||
|
}
|
||||||
|
if err := d.Scroll(ctx, 195, containerY, 195, containerY-120, 300*time.Millisecond); err != nil {
|
||||||
|
t.Fatalf("Scroll in the container: %v", err)
|
||||||
|
}
|
||||||
|
if after := read(containerScroll); after <= 0 {
|
||||||
|
t.Errorf(
|
||||||
|
"container scrollTop after = %d, want > 0: the container never scrolled",
|
||||||
|
after,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if after := read(pageScroll); after != 0 {
|
||||||
|
t.Errorf(
|
||||||
|
"scrollY = %d, want 0: a scroll inside a container moved the page instead",
|
||||||
|
after,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScroll_ReportsAGestureThatReachesNoElement keeps the scroll path on the
|
||||||
|
// same footing as the tap path: a page that cannot bring the point into the
|
||||||
|
// viewport has to say the gesture reached nothing rather than read as a step
|
||||||
|
// that scrolled.
|
||||||
|
func TestScroll_ReportsAGestureThatReachesNoElement(t *testing.T) {
|
||||||
|
const page = `<body style="margin:0;overflow:hidden">
|
||||||
|
<div style="height:80px">top</div>
|
||||||
|
<style>html{overflow:hidden}</style></body>`
|
||||||
|
server := httptest.NewServer(
|
||||||
|
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
_, _ = w.Write([]byte(page))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
d := New()
|
||||||
|
defer d.Terminate(context.Background())
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := d.Launch(ctx, server.URL, false, nil); err != nil {
|
||||||
|
t.Fatalf("Launch: %v", err)
|
||||||
|
}
|
||||||
|
err := d.Scroll(ctx, 100, 5000, 100, 4800, 300*time.Millisecond)
|
||||||
|
if !errors.Is(err, driver.ErrGestureUndelivered) {
|
||||||
|
t.Fatalf(
|
||||||
|
"Scroll far below an unscrollable page: err = %v, want ErrGestureUndelivered",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSwipe_DeliversATrustedDragToARowHandler covers what the manual says
|
||||||
|
// sideways swipes are for. Script-dispatched pointer events carry isTrusted
|
||||||
|
// false, which is the mark of a gesture the browser never routed: nothing in
|
||||||
|
// the page's own input pipeline saw it, so scrolling, touch-action and any
|
||||||
|
// handler that filters on trust behave as if the finger never moved.
|
||||||
|
func TestSwipe_DeliversATrustedDragToARowHandler(t *testing.T) {
|
||||||
|
server := gesturesServer(t)
|
||||||
|
d := New()
|
||||||
|
defer d.Terminate(context.Background())
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := d.Launch(ctx, server.URL, false, nil); err != nil {
|
||||||
|
t.Fatalf("Launch: %v", err)
|
||||||
|
}
|
||||||
|
if err := d.Swipe(ctx, 300, 40, 100, 40, 300*time.Millisecond); err != nil {
|
||||||
|
t.Fatalf("Swipe: %v", err)
|
||||||
|
}
|
||||||
|
var status string
|
||||||
|
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(
|
||||||
|
`document.getElementById("status").textContent`, &status)); err != nil {
|
||||||
|
t.Fatalf("evaluate: %v", err)
|
||||||
|
}
|
||||||
|
if status != "dismissed left trusted" {
|
||||||
|
t.Errorf("row status = %q, want %q", status, "dismissed left trusted")
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
:root { --frame-w: 390; --frame-h: 640; }
|
||||||
|
body { margin: 0; font: 16px sans-serif; }
|
||||||
|
#row { height: 80px; background: #eef; touch-action: pan-y; }
|
||||||
|
#inner { height: 200px; overflow-y: scroll; border: 2px solid #333; }
|
||||||
|
#inner div { height: 60px; }
|
||||||
|
#filler div { height: 60px; border-bottom: 1px solid #ccc; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="row" data-testid="row">swipe me</div>
|
||||||
|
<div id="status" data-testid="status">idle</div>
|
||||||
|
<div id="inner" data-testid="inner"></div>
|
||||||
|
<div id="filler"></div>
|
||||||
|
<script>
|
||||||
|
const inner = document.getElementById('inner');
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.textContent = 'inner ' + i;
|
||||||
|
inner.appendChild(item);
|
||||||
|
}
|
||||||
|
const filler = document.getElementById('filler');
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.textContent = 'page ' + i;
|
||||||
|
filler.appendChild(item);
|
||||||
|
}
|
||||||
|
const row = document.getElementById('row');
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
let startX = 0, startY = 0, tracking = false;
|
||||||
|
row.addEventListener('pointerdown', function(e) {
|
||||||
|
startX = e.clientX;
|
||||||
|
startY = e.clientY;
|
||||||
|
tracking = true;
|
||||||
|
row.setPointerCapture(e.pointerId);
|
||||||
|
});
|
||||||
|
row.addEventListener('pointermove', function(e) {
|
||||||
|
if (!tracking) return;
|
||||||
|
const dx = e.clientX - startX, dy = e.clientY - startY;
|
||||||
|
if (Math.abs(dx) <= 40 || Math.abs(dx) <= Math.abs(dy)) return;
|
||||||
|
tracking = false;
|
||||||
|
status.textContent =
|
||||||
|
(dx < 0 ? 'dismissed left' : 'dismissed right') +
|
||||||
|
(e.isTrusted ? ' trusted' : ' untrusted');
|
||||||
|
});
|
||||||
|
row.addEventListener('pointerup', function() { tracking = false; });
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in new issue
Block a user