diff --git a/internal/driver/chrome/driver.go b/internal/driver/chrome/driver.go
index dcdcfbe..e96f8d3 100644
--- a/internal/driver/chrome/driver.go
+++ b/internal/driver/chrome/driver.go
@@ -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 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}));
- }
- el.dispatchEvent(new PointerEvent('pointerup', {clientX: %d, clientY: %d, bubbles: true}));
-})();`,
- fromX, fromY,
- millis,
- toX, fromX, toY, fromY,
- fromX, fromY,
- fromX, fromY,
- toX, toY,
+ atX, atY, err := pointInView(runCtx, fromX, fromY)
+ if err != nil {
+ return err
+ }
+ 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);
diff --git a/internal/driver/chrome/driver_test.go b/internal/driver/chrome/driver_test.go
index fa0f9a3..d94e64c 100644
--- a/internal/driver/chrome/driver_test.go
+++ b/internal/driver/chrome/driver_test.go
@@ -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 = `
+
+
idle
+
+`
+
+// 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 = `
+
top
+
+`
+ 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 = `
+
+
none
+`
+ 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 = `
+
edit me
+
none
+`
+
+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 = `
+
top
+`
+ 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")
+ }
+}
diff --git a/internal/driver/chrome/testdata/gestures.html b/internal/driver/chrome/testdata/gestures.html
new file mode 100644
index 0000000..b2f29db
--- /dev/null
+++ b/internal/driver/chrome/testdata/gestures.html
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+