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
+662 -27

No files matched your search

+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")
}
}