diff --git a/internal/driver/chrome/driver.go b/internal/driver/chrome/driver.go index 829a8a2..0e0c2cc 100644 --- a/internal/driver/chrome/driver.go +++ b/internal/driver/chrome/driver.go @@ -131,13 +131,17 @@ func (d *Driver) Terminate(_ context.Context) error { return nil } -func (d *Driver) Tap(_ context.Context, x, y int) error { - return chromedp.Run(d.tabCtx, +func (d *Driver) Tap(ctx context.Context, x, y int) error { + runCtx, cancel := d.runCtx(ctx) + defer cancel() + return chromedp.Run(runCtx, chromedp.MouseClickXY(float64(x), float64(y)), ) } -func (d *Driver) TapSelector(_ context.Context, selector string) error { +func (d *Driver) TapSelector(ctx context.Context, selector string) error { + runCtx, cancel := d.runCtx(ctx) + defer cancel() target, isXPath, err := TranslateStringSelector(selector) if err != nil { // Fall back to passing the string straight through; chromedp will @@ -145,9 +149,9 @@ func (d *Driver) TapSelector(_ context.Context, selector string) error { target = selector } if isXPath { - return chromedp.Run(d.tabCtx, chromedp.Click(target, chromedp.NodeVisible, chromedp.BySearch)) + return chromedp.Run(runCtx, chromedp.Click(target, chromedp.NodeVisible, chromedp.BySearch)) } - return chromedp.Run(d.tabCtx, chromedp.Click(target, chromedp.NodeVisible)) + return chromedp.Run(runCtx, chromedp.Click(target, chromedp.NodeVisible)) } // doubleTapGap is the inter-tap delay for DoubleTap: short enough to land both @@ -177,8 +181,10 @@ func webDoubleTap(ctx context.Context, tap func() error) error { return tap() } -func (d *Driver) InputText(_ context.Context, text string) error { - return chromedp.Run(d.tabCtx, +func (d *Driver) InputText(callerCtx context.Context, text string) error { + runCtx, cancel := d.runCtx(callerCtx) + defer cancel() + return chromedp.Run(runCtx, chromedp.ActionFunc(func(ctx context.Context) error { // Select any existing content so InsertText replaces rather than appends. if err := chromedp.Evaluate(` @@ -201,8 +207,10 @@ func (d *Driver) ReplacesTextOnInput() bool { // EraseText clears the focused field. InputText above already replaces via // select-all, so the character count is not needed to bound the deletion. -func (d *Driver) EraseText(_ context.Context, _ int) error { - return chromedp.Run(d.tabCtx, +func (d *Driver) EraseText(callerCtx context.Context, _ int) error { + runCtx, cancel := d.runCtx(callerCtx) + defer cancel() + return chromedp.Run(runCtx, chromedp.ActionFunc(func(ctx context.Context) error { if err := chromedp.Evaluate(` (function() { @@ -216,7 +224,9 @@ func (d *Driver) EraseText(_ context.Context, _ int) error { ) } -func (d *Driver) Swipe(_ 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) + defer cancel() millis := max(duration.Milliseconds(), 50) script := fmt.Sprintf(` (function() { @@ -238,18 +248,22 @@ func (d *Driver) Swipe(_ context.Context, fromX, fromY, toX, toY int, duration t fromX, fromY, toX, toY, ) - return chromedp.Run(d.tabCtx, chromedp.Evaluate(script, nil)) + return chromedp.Run(runCtx, chromedp.Evaluate(script, nil)) } -func (d *Driver) PressKey(_ context.Context, key string) error { +func (d *Driver) PressKey(ctx context.Context, key string) error { k, ok := keyMap[key] if !ok { return fmt.Errorf("unsupported key: %q", key) } - return chromedp.Run(d.tabCtx, chromedp.KeyEvent(k)) + runCtx, cancel := d.runCtx(ctx) + defer cancel() + return chromedp.Run(runCtx, chromedp.KeyEvent(k)) } -func (d *Driver) LongPress(_ context.Context, x, y int) error { +func (d *Driver) LongPress(ctx context.Context, x, y int) error { + runCtx, cancel := d.runCtx(ctx) + defer cancel() script := fmt.Sprintf(` (function() { const el = document.elementFromPoint(%d, %d); @@ -263,7 +277,7 @@ func (d *Driver) LongPress(_ context.Context, x, y int) error { x, y, x, y, ) - return chromedp.Run(d.tabCtx, chromedp.Evaluate(script, nil)) + return chromedp.Run(runCtx, chromedp.Evaluate(script, nil)) } // keyMap covers the keys web specs may emit (enter/tab/escape/arrows). diff --git a/internal/driver/chrome/driver_test.go b/internal/driver/chrome/driver_test.go index 399f663..166d8d8 100644 --- a/internal/driver/chrome/driver_test.go +++ b/internal/driver/chrome/driver_test.go @@ -5,10 +5,52 @@ package chrome import ( "context" "encoding/json" + "errors" "testing" "time" ) +// TestActionMethods_HonorCallerCancellation confirms the DeviceDriver action +// methods route through runCtx so a cancelled caller context aborts the CDP +// round-trip instead of blocking on d.tabCtx. Without this a hung browser would +// ignore step deadlines and Ctrl-C. +func TestActionMethods_HonorCallerCancellation(t *testing.T) { + d := New() + defer d.Terminate(context.Background()) + launchCtx, launchCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer launchCancel() + if err := d.Launch(launchCtx, "data:text/html,", false, nil); err != nil { + t.Fatalf("Launch: %v", err) + } + + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + + actions := map[string]func() error{ + "Tap": func() error { return d.Tap(cancelled, 1, 1) }, + "Swipe": func() error { return d.Swipe(cancelled, 1, 1, 2, 2, 50*time.Millisecond) }, + "LongPress": func() error { return d.LongPress(cancelled, 1, 1) }, + "PressKey": func() error { return d.PressKey(cancelled, "enter") }, + "InputText": func() error { return d.InputText(cancelled, "x") }, + "EraseText": func() error { return d.EraseText(cancelled, 1) }, + "TapSelector": func() error { return d.TapSelector(cancelled, "#go") }, + } + for name, action := range actions { + t.Run(name, func(t *testing.T) { + done := make(chan error, 1) + go func() { done <- action() }() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("want context.Canceled, got %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("action ignored cancelled caller ctx and blocked") + } + }) + } +} + // TestHierarchy_EditableFlag confirms the injected hierarchy script marks text // inputs, textareas, and contenteditable elements editable while leaving // buttons and non-text inputs alone.