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 @@ + + + + + + +
swipe me
+
idle
+
+
+ + +