Advanced Interactions & Test Assertions
Once a Playwright suite moves past clicking buttons and reading text, it runs into the hard parts of browser automation: pointer gestures that frameworks intercept and re-dispatch, file inputs that bypass click simulation, wizard forms that span multiple routes, and backend traffic that turns a green test red overnight. This guide covers the four interaction areas that decide whether an end-to-end suite is trustworthy or merely green most of the time. Each area links to a focused technique guide, and each of those links down to step-by-step walkthroughs you can copy into a real spec.
The thread running through all four areas is the same: assert on observable state, never on elapsed time. Playwright's auto-waiting resolves when an element is actionable, but it cannot know when your business logic finished. The patterns below pair every interaction with an explicit assertion or a network wait, so a passing test means the application actually did the work, not that a timer expired.
The problem these techniques solve
A suite that only clicks buttons and reads text rarely fails for an interesting reason. The failures that erode trust come from the seams: a drag that visually lands but never fires the framework's drop handler, an upload that the OS file dialog blocks in headless CI, a wizard that loses its session between routes, an analytics call that times out and drags a networkidle wait over the edge. These are not edge cases—they are the parts of an application that carry the most business value, which is exactly why they are tested last and break first.
The common failure mode is implicit timing. A test does an action, sleeps, then checks a result. Locally the sleep is generous enough; in a loaded CI runner the same sleep is too short and the test flakes, or it is too long and the suite crawls. Every technique on this page replaces an implicit timer with an explicit signal—a DOM mutation, a resolved waitForResponse(), a download event—so the test is both faster and deterministic. The mental shift is from "do the thing and hope" to "do the thing and wait for proof the thing happened."
A mental model: the three layers of an interaction
Every advanced interaction in this guide decomposes into three layers, and most flaky tests confuse them:
- The trigger. The API call you make—
dragTo(),setInputFiles(),fill(),route(). This layer has Playwright's built-in actionability checks (visible, stable, enabled, receives events) baked in, so a well-located trigger almost never needs manual waiting before it runs. - The effect. What the application does in response—a network round-trip, a re-render, a state machine transition, a file written to disk. This layer is yours, and Playwright has no inherent knowledge of when it completes. It is the layer where you must supply an explicit wait.
- The assertion. The observable proof that the effect landed—a retrying
expect(locator)check, a parsed response body, bytes on disk. This layer closes the loop and is what makes a green test meaningful.
Keeping these three layers distinct is the single highest-value habit in interaction testing. When a test flakes, the question is always which layer raced: did you trigger before the element was ready (rare, Playwright handles it), did you assert before the effect finished (common, fix with a network wait), or did you register a listener after the effect already fired (the classic race, fix by ordering the listener first)?
How this guide fits the wider suite
Interactions never stand alone. Before you drag an element you have to locate it, which depends on the selector discipline in Reliable Selector Strategies for Playwright. When an interaction misbehaves in CI but passes locally, you diagnose it with the tooling in Debugging & Test Observability. And the same route() machinery that mocks an API for a test is what you reach for when pulling data with Web Scraping & Data Extraction. This guide is the interaction-and-assertion middle layer between those concerns.
A practical reading order: get selectors stable first, then learn the interactions here, then wire in observability so failures are cheap to investigate. The four areas below are independent—you can adopt drag-and-drop patterns without touching network interception—but they share one rule: an interaction is only complete when an assertion confirms its effect.
It also helps to fix the project foundations before any of these techniques pay off. The configuration, fixtures, and worker model in Playwright Setup & Core Architecture decide whether your interaction tests run isolated and in parallel; the Playwright Config & Fixtures guide in particular governs acceptDownloads, base URL, and the per-test context that every example here assumes. Treat this guide as the layer that sits on top of a configured project, not a replacement for one.
Drag & Drop Workflows
Drag and drop is the interaction most likely to look correct and be wrong. The browser fires mousedown, a sequence of mousemove events, and mouseup, and modern frameworks translate those into their own HTML5 dragstart/dragover/drop lifecycle—often re-dispatching synthetic events that the naive automation never sends. Playwright's locator.dragTo() collapses the pointer choreography into one atomic call that respects hydration boundaries, and it covers the large majority of real cases.
Where dragTo() falls short is custom renderers: a <canvas> whiteboard, a virtualized list, or a library that reads raw pointer coordinates. For those you drop to page.mouse with bounding-box math and interpolated steps, never hardcoded offsets. Either way, the completion signal is a DOM mutation—a target class flipping to active-drop, a success indicator appearing—not the absence of an error. Read Drag & Drop Workflows for the full pointer-sequence playbook, and the focused walkthrough on simulating HTML5 drag and drop in Playwright when the high-level API will not fire the framework's own drag events.
The reliability rule for gestures: calculate coordinates from boundingBox() at runtime so the test survives viewport scaling and layout shifts, and wrap each drag in test.step() so the trace shows exactly which transfer failed.
The high-level path is one call, and you should reach for it first because it inherits Playwright's actionability checks on both the source and the target:
import { test, expect } from '@playwright/test';
test('reordering a list moves the card to the top', async ({ page }) => {
await page.goto('/board');
const card = page.getByRole('listitem', { name: 'Draft proposal' });
const target = page.getByRole('list', { name: 'In review' });
// dragTo() dispatches the full pointer sequence atomically: it waits for
// both source and target to be actionable before moving.
await card.dragTo(target);
// The effect is a DOM reparent — assert the card now lives under the target.
await expect(target.getByRole('listitem', { name: 'Draft proposal' }))
.toBeVisible();
});
When a custom renderer ignores dragTo()—the common symptom is a drag that animates but leaves application state unchanged—drop to the manual pointer path. The key is steps: a single jump from source to target often skips the dragover events a library throttles on, so interpolate the move and let each intermediate position register.
import { test, expect } from '@playwright/test';
test('canvas node drags to a new coordinate', async ({ page }) => {
await page.goto('/diagram');
const node = page.getByTestId('node-start');
const box = await node.boundingBox(); // runtime geometry, not constants
if (!box) throw new Error('node not rendered');
const startX = box.x + box.width / 2;
const startY = box.y + box.height / 2;
await page.mouse.move(startX, startY); // hover the source first
await page.mouse.down(); // begin the gesture
await page.mouse.move(startX + 200, startY + 120, { steps: 20 }); // interpolate
await page.mouse.up(); // commit the drop
// Assert on the application's own state readout, not on pixels.
await expect(page.getByTestId('node-start-x')).toHaveText('200');
});
Two failure modes recur. First, a drop handler that reads dataTransfer will see nothing from raw mouse moves because there is no native drag session; for those HTML5 cases you dispatch the events explicitly, which the simulating HTML5 drag and drop in Playwright walkthrough covers in full. Second, a drag that "works" but lands on the wrong slot usually means the target moved between boundingBox() and the drop—recompute the target box immediately before mouse.up() when the layout reflows mid-gesture. Locating the source and target reliably is itself a selector problem; the role-based queries above come from getByRole & Accessibility Selectors.
File Uploads & Downloads
File inputs are a deliberate browser security boundary. You cannot script a real OS file picker, and you should not try. Playwright sidesteps the dialog entirely: locator.setInputFiles() attaches File buffers directly to the <input type="file"> node, behaving identically in headed and headless mode. The same DOM-level injection works for a single document or an array of paths, and you always resolve those paths with path.join(__dirname, ...) so the spec runs the same on macOS, Linux, and a containerized CI runner.
Downloads are the mirror image. Set acceptDownloads: true on the context, then capture the download event—paired with its trigger inside Promise.all() so the listener is in place before the click fires. From the Download object you call saveAs() to a deterministic path and validate the bytes on disk: assert stats.size is greater than zero rather than against a brittle hardcoded length. Start with File Uploads & Downloads, then the batch walkthrough on handling multiple file uploads in Playwright and the verification walkthrough on automating file downloads and verifying contents.
import { test, expect } from '@playwright/test';
import path from 'path';
import { fileURLToPath } from 'url';
const dir = path.dirname(fileURLToPath(import.meta.url));
test('upload completes and the UI confirms it', async ({ page }) => {
await page.goto('/upload');
// Register the network wait BEFORE the action that triggers it.
const uploaded = page.waitForResponse(
(res) => res.url().includes('/upload') && res.status() === 200,
);
// setInputFiles injects the file directly — no OS dialog is involved.
await page.locator('input[type="file"]').setInputFiles(
path.join(dir, 'fixtures', 'report.pdf'),
);
await uploaded; // server acknowledged the payload
// Assert on rendered confirmation, not on a timer.
await expect(page.getByText('Upload complete')).toBeVisible();
});
setInputFiles() accepts more than a path string. Pass an array for multi-file inputs, pass an empty array to clear a selection, or pass an in-memory object with name, mimeType, and a Buffer body when you want to test a specific payload without a fixture file on disk—useful for asserting how the application handles an oversized or malformed upload. When the page hides the real input[type="file"] behind a styled button (most design systems do), do not click the button and hunt for a dialog; locate the hidden input directly and call setInputFiles() on it. If the input is created only after a click, wrap the trigger and the file chooser together with page.waitForEvent('filechooser') and call setFiles() on the resulting chooser.
Downloads need acceptDownloads: true on the context—set it once in the config so every test inherits it—and the listener must be in place before the click. Capture the Download object, save it to a deterministic path, then assert against the file's contents rather than the suggested filename, which servers often randomize:
import { test, expect } from '@playwright/test';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
test('export downloads a non-empty CSV with the right header', async ({ page }) => {
await page.goto('/reports');
// Arm the download listener BEFORE the click so the event is never missed.
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Export CSV' }).click();
const download = await downloadPromise;
// saveAs() to a temp path we control, not the OS default download folder.
const target = path.join(os.tmpdir(), `export-${Date.now()}.csv`);
await download.saveAs(target);
const contents = await fs.readFile(target, 'utf-8');
expect(contents.length).toBeGreaterThan(0); // bytes actually landed
expect(contents.split('\n')[0]).toContain('id,name,total'); // real header
});
The most common download failure in CI is a missing acceptDownloads, which makes the download event fire but saveAs() reject. The second is asserting on download.suggestedFilename() when the server sets a hashed name—prefer path and contents. For verifying structure rather than presence, the automating file downloads and verifying contents walkthrough parses the saved bytes into rows and asserts row counts, and the handling multiple file uploads in Playwright walkthrough covers ordering and per-file progress when an array of paths is attached at once.
Form Automation & Input Handling
Forms expose the difference between Playwright's two ways of entering text. fill() sets the value in one shot and dispatches a single input event—fast, and the right choice when you want to bypass client-side keystroke sanitization to probe backend validation. pressSequentially() emits real per-character keydown/keypress/input events with an optional delay, which is what debounced search boxes, autocompletes, and input masks need to behave as a user would see them. Choosing the wrong one is a common source of "works manually, fails in the test" reports.
Beyond text, real forms mean dropdowns (selectOption()), checkboxes and radios (check()/uncheck() with their built-in actionability waits), and multi-route wizards that carry state between steps. Wizards lean on a persistent context so cookies and storage survive navigation, plus conditional branching driven by isChecked() or waitForURL() rather than fixed sleeps. Begin with Form Automation & Input Handling, then the deep dives on automating multi-step forms with Playwright and on handling dropdowns, checkboxes, and radio buttons.
The submission step is where forms meet the network: register a waitForResponse() predicate matching the POST endpoint before clicking submit, parse the captured payload to confirm the data the UI actually sent, then assert the post-submit UI—a confirmation toast plus a URL change—as the completion marker.
The control APIs each carry their own actionability guarantees, which is why you almost never need a wait before them. selectOption() accepts a value, a label ({ label: 'United Kingdom' }), or an index, and it waits for the option to exist before choosing; check() and uncheck() are idempotent and verify the resulting state, so they will not double-toggle a control that is already in the wanted state. The snippet below shows a single form combining text, a select, a checkbox, and a network-backed submit:
import { test, expect } from '@playwright/test';
test('signup submits the values the user entered', async ({ page }) => {
await page.goto('/signup');
// fill() sets the value in one shot — fast, no per-keystroke events.
await page.getByLabel('Full name').fill('Ada Lovelace');
// pressSequentially() emits real keystrokes so the autocomplete reacts.
await page.getByLabel('City').pressSequentially('Lon', { delay: 80 });
await page.getByRole('option', { name: 'London' }).click();
await page.getByLabel('Country').selectOption({ label: 'United Kingdom' });
await page.getByLabel('Subscribe to updates').check(); // idempotent + waits
// Arm the response wait before the submit click to avoid a race.
const submitted = page.waitForResponse(
(r) => r.url().endsWith('/api/signup') && r.request().method() === 'POST',
);
await page.getByRole('button', { name: 'Create account' }).click();
// Inspect the actual payload the form sent — not the values we typed back.
const response = await submitted;
const sent = response.request().postDataJSON();
expect(sent.city).toBe('London');
expect(sent.subscribe).toBe(true);
// Completion marker: the post-submit navigation, asserted explicitly.
await page.waitForURL('**/welcome');
await expect(page.getByRole('heading', { name: 'Welcome, Ada' })).toBeVisible();
});
Multi-route wizards add a state dimension. Because each step is its own navigation, the test must survive the page reload without losing the answers from earlier steps—which is why wizards rely on the browser context persisting cookies and storage rather than on test variables. Drive the branching with isChecked() or waitForURL() so a conditional step ("show the company fields only for business accounts") is decided by the application's real state, not by a hardcoded assumption. The automating multi-step forms with Playwright walkthrough carries state across four routes and asserts at each boundary; the handling dropdowns, checkboxes, and radio buttons walkthrough covers the native versus custom-component split, where a styled select replacement needs role-based clicks instead of selectOption(). Choosing between a label query and a CSS query for these controls is a selector decision detailed in CSS Selector vs getByRole: When to Use Each.
Network Interception Basics
Network control is the highest-leverage technique in the whole guide because it severs the test from backend non-determinism. page.route(glob, handler) registers a handler on the page or context, and every matching request pauses inside the browser before it leaves. The handler then chooses: route.fulfill() answers from a fixed payload so the request never touches the network, route.continue() forwards it (optionally with rewritten headers, method, or body), and route.abort() blocks it—handy for killing analytics and fonts to speed CI.
Three rules prevent most interception bugs: register the handler before the request fires, terminate every matched route with exactly one of fulfill/continue/abort, and pair trigger actions with waitForResponse() inside Promise.all() so nothing races. Routes are scoped to their page or context and each test gets a fresh context, so handlers do not leak. Read Network Interception Basics for the routing lifecycle, then mocking API responses with Playwright for deterministic fixtures and intercepting and modifying network requests for rewriting traffic in flight.
import { test, expect } from '@playwright/test';
test('checkout shows the server error path', async ({ page }) => {
await page.route('**/api/checkout', async (route) => {
// Only the POST is failed; reads continue to the real backend.
if (route.request().method() === 'POST') {
await route.fulfill({
status: 503,
contentType: 'application/json',
body: JSON.stringify({ error: 'Service Unavailable' }),
});
} else {
await route.continue(); // every matched route MUST terminate.
}
});
await page.goto('/cart');
await page.getByRole('button', { name: 'Place order' }).click();
await expect(page.getByRole('alert')).toContainText('try again');
});
The handler API gives you three precise levers. route.fulfill() is for total isolation—the request never leaves the browser, so the test is immune to backend latency, data drift, and outages; it is how you exercise error paths (a 503, a malformed body, an empty list) that are hard to provoke against a real server. route.continue() forwards the request but lets you rewrite it first: change the method, inject an auth header, or replace the postData to test how the UI renders a response it would not normally request. route.abort() drops the request entirely, which both tests the application's failure handling and trims CI time when you abort fonts, images, and analytics that the test does not care about.
A subtle but important refinement is patching a response instead of replacing it. Rather than hand-writing a full fixture, let the request hit the real backend, read the live body, mutate one field, and fulfill with the result—so the test stays close to production shape while still being deterministic about the field under test:
import { test, expect } from '@playwright/test';
test('UI flags an account that the API marks as suspended', async ({ page }) => {
await page.route('**/api/account', async (route) => {
// Fetch the genuine response so the shape stays realistic.
const response = await route.fetch();
const body = await response.json();
body.status = 'suspended'; // patch exactly one field
await route.fulfill({ response, json: body }); // re-emit, headers preserved
});
await page.goto('/account');
await expect(page.getByRole('status')).toHaveText('Account suspended');
});
Order and termination are the two rules that prevent most interception defects. Register page.route() before the navigation or action that fires the request, because a handler attached afterward will miss requests already in flight. And every matched route must end in exactly one of fulfill, continue, abort, or fetch+fulfill; a handler that returns without terminating leaves the request hanging until the test times out. When several handlers match the same URL, Playwright runs them in reverse registration order and a handler can call route.fallback() to defer to the next one—handy for layering a default mock with a per-test override. Remove a handler mid-test with page.unroute() when a later step needs the real backend.
Interception also reaches outward: the trace-and-network workflow in Debugging & Test Observability reads the very requests you fulfill here, and the data work in Web Scraping & Data Extraction uses the same routing API to capture or replay responses. The two focused walkthroughs split the topic cleanly—mocking API responses with Playwright for fixture-driven fulfill(), and intercepting and modifying network requests for the continue() rewrite path.
A shared assertion discipline
Across all four areas the assertion style is identical, and getting it right matters more than any single API. Prefer expect(locator) web-first assertions—toBeVisible(), toHaveText(), toHaveCount()—because they retry until the condition holds or the timeout expires, absorbing the asynchronicity that drag animations, upload round-trips, and debounced inputs introduce. When the condition lives outside the DOM, reach for expect.poll() to retry an arbitrary function, or expect(response) after a waitForResponse() to validate the network result directly.
Use expect.soft() when you want several independent checks on one page to all report rather than stopping at the first failure—useful for a form that should show three validation messages at once. And never re-assert a value you just supplied: mocking /api/orders and then asserting the mock equals itself proves nothing. Assert the rendered effect—the rows that appeared, the toast that fired, the URL that changed—so a green test means the user-visible behavior is correct.
The payoff compounds. A suite built on retrying assertions and network waits is faster (no padded sleeps), more honest (failures point at real defects), and far less flaky in CI. When something does break, the artifacts described in Debugging & Test Observability turn a red run into a five-minute fix instead of an afternoon of guessing.
A few assertion patterns earn their place across all four areas. expect.poll() retries a plain function until it returns a truthy value or times out, which is the right tool when the proof of an effect lives in localStorage, an API you query directly, or a computed value rather than the DOM. expect.soft() records a failure but keeps the test running, so a form that should surface three validation messages reports all three in one run instead of failing on the first. And expect(locator).toHaveScreenshot() pins a visual result when a drag or a layout change has no clean textual signal—though pixel assertions need stable rendering, so reserve them for components that do not animate. The example below combines a poll on client state with a soft DOM assertion:
import { test, expect } from '@playwright/test';
test('draft autosaves and the indicator updates', async ({ page }) => {
await page.goto('/editor');
await page.getByRole('textbox', { name: 'Body' }).fill('First pass');
// Poll an out-of-DOM signal: the app persists drafts to localStorage.
await expect.poll(async () =>
page.evaluate(() => localStorage.getItem('draft-status')),
).toBe('saved');
// Soft assertion: report this even if a later check also fails.
await expect.soft(page.getByRole('status')).toHaveText('All changes saved');
});
Cross-cutting integration with the wider suite
None of these techniques live in isolation, and the most resilient suites wire them into the surrounding disciplines deliberately. Every interaction starts by locating an element, so the source and target queries throughout this guide depend on the stability rules in Reliable Selector Strategies for Playwright—a drag whose target is matched by a brittle CSS path will fail for reasons that have nothing to do with the gesture. Pair role and label queries with the interactions here, and a refactor that changes class names leaves your tests untouched.
When an interaction fails in a way the assertion message does not explain—a drop that silently no-ops, an upload that the server rejects—the diagnosis happens in Debugging & Test Observability. The trace records every pointer move, every network request your route handler saw, and a DOM snapshot at each step, so you can replay a failed drag frame by frame instead of guessing. Network interception and the trace viewer are complementary: the routes you fulfill() here are the exact requests the trace lists, which makes mismatches between expected and actual traffic obvious.
The routing API also bridges into data work. The same page.route() and route.fetch() machinery that mocks a backend for a test is what you reach for to capture, replay, or rate-limit responses when pulling data with Web Scraping & Data Extraction—interception is the shared substrate under both deterministic tests and resilient scrapers. Reading these three guides alongside this one turns four standalone techniques into one coherent workflow: locate reliably, interact deliberately, observe failures cheaply, and reuse the network layer wherever traffic matters.
CI/CD and production readiness
A suite that is green on a developer laptop and red in CI has usually hit one of a handful of environment gaps, and the interaction techniques here are the most sensitive to them. The first is headless rendering: file dialogs, drag animations, and download prompts all behave differently when there is no display server. Playwright runs headless by default and setInputFiles() plus the download event are designed to work identically with or without a display, but anything that assumes a visible OS dialog will break—another reason never to script the native file picker. Run the suite headless locally at least sometimes so CI is not the first place the headless path executes.
The second gap is concurrency. Interaction tests are I/O-bound—they wait on network round-trips and re-renders—so they parallelize well across workers, but only if each test is isolated. Playwright gives every test a fresh browser context, which means cookies, storage, and route handlers do not leak between workers; the patterns on this page rely on that isolation, so do not share mutable fixtures across tests. Configure workers to match the runner's CPU budget and let independent specs run concurrently rather than padding the suite with serial waits. For a fleet of runners, split the suite with sharding so each machine runs a slice and the wall-clock time drops near-linearly.
The third gap is environment shape: base URLs, timeouts, and acceptDownloads that differ between local and CI. Keep these in the project config so a download test does not pass locally and fail in CI for a missing flag. The headless image, the worker and sharding strategy, and the container that pins browser binaries are covered end to end in CI/CD Integration—treat that guide as the deployment counterpart to the techniques here, because an interaction suite is only production-ready once it runs the same in a container as on a desk.
import { test, expect } from '@playwright/test';
// A test written so it is identical headless and headed: no OS dialogs,
// explicit network waits, assertions on observable state. This is the
// shape that survives the move from a laptop to a sharded CI fleet.
test('order flow is deterministic across environments', async ({ page }) => {
await page.route('**/api/inventory', (route) =>
route.fulfill({ json: { sku: 'A1', inStock: true } }),
);
await page.goto('/product/A1');
const ordered = page.waitForResponse('**/api/orders');
await page.getByRole('button', { name: 'Buy now' }).click();
await ordered;
await expect(page.getByRole('status')).toHaveText('Order confirmed');
});
Governance and maintenance
Interaction tests rot faster than unit tests because they sit on top of the most-changed surfaces of an application—forms, file flows, and the API contracts they exercise. Keeping the suite trustworthy over time is a governance problem, not just a coding one. Pin the Playwright version and its browser binaries together; a browser update can change how a drag dispatches events or how a download names a file, so upgrade deliberately and run the full suite on the bump rather than letting binaries drift. When you mock an endpoint with fulfill(), the fixture is a copy of a contract that can change underneath you, so review mocks against the real API on a schedule—a mock that no longer matches production is worse than no mock, because it makes a broken integration look healthy.
Flaky-test hygiene is the other half of governance. A test that fails intermittently teaches the team to ignore red, which eventually hides a real defect. Treat every flake as a defect in the test's timing layer—almost always a listener registered after the action, an assertion against elapsed time instead of state, or a shared fixture mutated across workers—and fix the cause rather than papering over it with a blanket retry. Retries are a safety net for genuinely non-deterministic infrastructure, not a substitute for deterministic tests. The detection and triage workflow lives in Flaky Test Management, which covers quarantining a flaky spec, measuring its failure rate, and deciding when a retry is acceptable versus when the test must be rewritten.
Finally, assign ownership. Each interaction area should map to the team that owns the underlying feature, so a failing upload test routes to the people who can fix either the test or the upload. Tag specs by area, keep the focused walkthroughs under each technique as the canonical reference for newcomers, and date your mocks so a stale fixture is visible at a glance. A suite governed this way stays a source of confidence; one left to drift becomes a tax everyone pays and no one trusts.
Frequently Asked Questions
Should I use fill() or pressSequentially() for form inputs?
Use fill() when you want to set a value quickly or deliberately bypass client-side keystroke handling to test backend validation. Use pressSequentially() with a delay when the field debounces input, drives an autocomplete, or applies an input mask, because those features only react to real per-character events.
Why do my interactions pass locally but fail in CI?
The most common cause is timing: a network wait or route handler registered after the action it should observe, which races in slower CI environments. Register waitForResponse() and page.route() before the triggering action, and assert on observable DOM or network state instead of fixed delays.
When should I drop from locator.dragTo() to raw page.mouse moves?
Stay on locator.dragTo() for standard HTML drag and drop—it dispatches the full pointer sequence atomically. Switch to page.mouse with bounding-box math only for canvas elements and custom renderers that read raw coordinates and do not respond to the high-level API.