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:
pj committed 2026-08-16 17:44:23 +05:30
1 parent 05c8523762
commit 45dd52c5d9
3 files changed
+661 -26

No files matched your search

+183 -26
View File
@@ -11,6 +11,7 @@ import (
"sync"
"time"
"github.com/chromedp/cdproto/cdp"
"github.com/chromedp/cdproto/input"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/page"
@@ -201,14 +202,86 @@ func (d *Driver) Terminate(_ context.Context) error {
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 {
runCtx, cancel := d.runCtx(ctx)
defer cancel()
atX, atY, err := pointInView(runCtx, x, y)
if err != nil {
return err
}
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 {
runCtx, cancel := d.runCtx(ctx)
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.
target = selector
}
if err := requireSelectorMatch(runCtx, target, selector); err != nil {
return err
}
if isXPath {
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.
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 {
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 {
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 {
if err := tap(); err != nil {
// webDoubleTap dispatches the pair a browser reads as one double click. Blink
// 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
}
timer := time.NewTimer(doubleTapGap)
@@ -248,7 +363,7 @@ func webDoubleTap(ctx context.Context, tap func() error) error {
return ctx.Err()
case <-timer.C:
}
return tap()
return tap(2)
}
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 {
runCtx, cancel := d.runCtx(ctx)
defer cancel()
millis := max(duration.Milliseconds(), 50)
script := fmt.Sprintf(`
(function() {
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}));
atX, atY, err := pointInView(runCtx, fromX, fromY)
if err != nil {
return err
}
el.dispatchEvent(new PointerEvent('pointerup', {clientX: %d, clientY: %d, bubbles: true}));
})();`,
fromX, fromY,
millis,
toX, fromX, toY, fromY,
fromX, fromY,
fromX, fromY,
toX, toY,
toX, toY = toX-(fromX-atX), toY-(fromY-atY)
steps := max(int(duration.Milliseconds())/16, 1)
actions := []chromedp.Action{touchAt(input.TouchStart, atX, atY)}
for i := 1; i <= steps; i++ {
actions = append(actions, touchAt(input.TouchMove,
atX+(toX-atX)*i/steps, atY+(toY-atY)*i/steps))
}
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 {
@@ -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 {
runCtx, cancel := d.runCtx(ctx)
defer cancel()
x, y, err := pointInView(runCtx, x, y)
if err != nil {
return err
}
script := fmt.Sprintf(`
(function() {
const el = document.elementFromPoint(%d, %d);
+426
View File
@@ -9,11 +9,14 @@ import (
"net"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/chromedp/chromedp"
"github.com/priyanshujain/sanderling/internal/driver"
"github.com/priyanshujain/sanderling/internal/hierarchy"
)
// 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)
}
}
// 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
View File
@@ -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>