feat: low-level interaction endpoints (mouse-wheel, init-script, capture-network, capture-requests) - #4210
feat: low-level interaction endpoints (mouse-wheel, init-script, capture-network, capture-requests)#4210nayrosk wants to merge 4 commits into
Conversation
40e94fb to
a82432e
Compare
|
Rebased onto the current This should merge cleanly now. Happy to split it into smaller PRs or address any review feedback — just let me know. Thanks! |
|
Thanks for the advanced interaction and capture endpoints. The feature set is useful, but this version needs changes before it can merge. Please:
Please keep the scope focused on these endpoints and their production contracts. |
a82432e to
87c7185
Compare
Some sites (notably Instagram DMs) virtualise nested scrollable containers and ignore both programmatic scrollTop and dispatched WheelEvents -- only a real OS-level wheel event at the container's coordinates triggers their lazy load. The existing /scroll endpoint dispatches at the page level via mouse.wheel without prior cursor positioning, which is too coarse for this case. This adds POST /tabs/:tabId/mouse-wheel with three coordinate modes: - ref: element ref resolved to its bounding-box centre - x, y: explicit page coordinates - default: viewport centre Mirrors the conventions of /scroll and /click (no auth middleware, withTabLock, refToLocator + refreshTabRefs fallback, StaleRefsError, pluginEvents.emit) and ships with the matching @openapi annotation so it appears in /docs and /openapi.json.
…ption
Modern SPA frameworks (React/Next/etc.) often capture `fetch` and
`XMLHttpRequest` references at bundle init time, before any user
script can run. In-page monkey-patches via /evaluate are bypassed
because the bundle holds its own references. Even page.addInitScript
hooks installed before the document loads can be defeated by
Service Workers that intercept fetches before they reach the page.
Two endpoints address these gaps:
POST /tabs/:tabId/init-script
Wraps Playwright's page.addInitScript(). The script is evaluated
before any other script on every navigation in the tab's page
context. Useful for hooks that must beat first-byte JS.
POST /tabs/:tabId/capture-network
Wraps page.on("response") for a bounded duration, returning every
response whose URL matches a regex (default /graphql/i). Operates
at the browser network layer, above the Service Worker and above
any in-page JS, so it cannot be bypassed by either. Bodies are
capped per-capture and per-count to keep responses manageable.
Both endpoints follow the existing conventions:
- authMiddleware (sensitive: arbitrary script / response bodies)
- withTabLock for the navigate-equivalent operations
- emit tab:init-script / tab:capture-network plugin events
- standard error handling via handleRouteError
…capture
Mirrors /capture-network but uses page.on("request") instead of
page.on("response"), exposing the request URL, method, POST body, and
headers. Useful when a page bundle captures fetch/XHR references in a
closure before any user JS runs (e.g. Instagram), bypassing window-level
hooks installed via evaluate.
Body params identical to /capture-network plus an includeHeaders flag.
Documents and hardens the mouse-wheel / init-script / capture-network / capture-requests endpoints so they meet the production contract expected of the rest of the API. - OpenAPI: add complete @openapi blocks for capture-network, capture-requests and init-script (none had one, which broke the route-coverage and openapi.json freshness assertions in tests/unit/openapi.test.js), flesh out the mouse-wheel block with its 422 and Error schemas, declare the bearer requirement on the three auth-gated routes, add the Network tag, and regenerate openapi.json. - capture-network: track every started response.text() read and await the in-flight ones (under a bounded 5s grace period) before responding, so a response arriving near the deadline is no longer silently dropped. Capture slots are now reserved at event time, which also fixes ordering and makes maxCaptures deterministic. Listeners are detached in a finally block, and a failed read keeps its full __BODY_ERROR__ diagnostic instead of being truncated to maxBodyBytes. - mouse-wheel: bound the bounding-box lookup the way the click path does (Math.max(500, Math.min(3000, remainingBudget())), 422 on detach) instead of inheriting Playwright's 30s default and burning the handler budget into a 500. The pre-wheel refs refresh now uses the same budget, and catches its own pre_wheel_refs_timeout label rather than pre_click_refs_timeout. - Validation: user-supplied regexes and numeric limits are validated as client input and return 400 with an actionable message. Invalid patterns previously reached sendError() as a 500, and out-of-range durations were silently clamped. Ranges are documented in the spec and rejected rather than clamped. - Tests: unit coverage for the validators and the capture helpers (including the in-flight drain and the bounded-drain fallback), plus endpoint-level regression tests asserting 400/403/404 against a live server without a browser. Rebased onto master (v1.13.1).
87c7185 to
9847c4d
Compare
|
Thanks for the detailed review. All five points are addressed, rebased onto current 1. OpenAPI blocks + regenerated
|
Summary
Four new endpoints that fill gaps in the existing interaction surface, all needed in practice to drive modern anti-automation web apps (e.g. Instagram DMs):
POST /tabs/:tabId/mouse-wheel— realpage.mouse.wheel()dispatched at a specific element or coordinatePOST /tabs/:tabId/init-script— wrapspage.addInitScript()so a hook runs on every navigation before any page scriptPOST /tabs/:tabId/capture-network— wrapspage.on("response")for a bounded duration, captures matching response bodies at the browser network layer (above the Service Worker, above any in-page closure)POST /tabs/:tabId/capture-requests— wrapspage.on("request")for the symmetric request side, exposing URL + method + POST body + headers (essential when the page bundle hides outgoing payloads behind closure-cached primitives)Each addresses a distinct interception level. Together they cover the full stack: DOM events → page lifecycle → network (both directions).
1.
/tabs/:tabId/mouse-wheelThe existing
/scrollendpoint callsmouse.wheel()without prior cursor positioning, so it targets wherever the cursor happens to be — too coarse for nested scrollable containers. Some sites (notably Instagram DMs) virtualise their message lists and ignore both programmaticscrollTopand JS-dispatchedWheelEvents; only a real wheel at the inner container's coordinates triggers their lazy load.Three coordinate modes (priority):
ref>(x, y)> viewport centre./click—refToLocator, falls back torefreshTabRefswithpre_wheelreason, throwsStaleRefsErrorif still unresolvablewithTabLocktab:mouse-wheel2.
/tabs/:tabId/init-script(authMiddleware)Wraps
page.addInitScript({ content: script }). The script is evaluated in the page world before any other script on every navigation in the tab. Useful for hooks that must beat first-byte JS (e.g. install afetchwrapper before the bundle imports it).3.
/tabs/:tabId/capture-network(authMiddleware)Attaches
page.on("response")fordurationMs, then detaches and returns every matching response. Operates at the browser network layer, so it captures:window.fetchreferences the page bundle cached before any in-page hook had a chanceIn practice this is the only reliable way to observe outgoing API traffic of SPAs that aggressively cache primitives at bundle init time.
4.
/tabs/:tabId/capture-requests(authMiddleware)Symmetric counterpart to
/capture-network:page.on("request")instead ofpage.on("response"). Returns POST body + headers for each matching request, captured at the browser network layer.Motivation:
/capture-networkreveals what data the page receives, but is silent on what the page sends. Without the outgoing payload (CSRF tokens, doc IDs, pagination cursors, signed query params), it's impossible to replay or extend a captured GraphQL operation from outside the page. Hook-based approaches (window.fetchoverride,XMLHttpRequest.prototype.sendoverride) fail against bundles that cache the original references at module init — verified empirically against Instagram's web client. Only a Playwright-level listener catches every outbound request.headersreturns the resolved request headers includingcookie,x-csrftoken,x-fb-lsd,x-fb-friendly-name, etc. SetincludeHeaders: falseto omit them/capture-network(same handler shape, same lifecycle, same cleanup) for review symmetryVerification
All four endpoints exercised on an Instagram DM thread that:
/scroll's page-level wheel)fetchreference captured at module init (defeats any in-page hook on eitherwindow.fetchorXMLHttpRequest.prototype)/api/graphql(defeats most network observers)Results:
/mouse-wheelat the container's centre coords withdeltaY=-1500:scrollHeightgrew from 1230 → 5382 in 8 batches (lazy load triggered)/init-scriptinstalled afetchwrapper that captured a manualfetch("/api/graphql")call (verified hook installation), but missed all 35 of the page's own GraphQL fetches (confirms the closure-cache problem)/capture-networkfor 25 s while navigating to the thread: captured all 35 GraphQL responses (827 KB), including the one carrying the message list/capture-requestsfor 15 s while refreshing the thread: captured 36 GraphQL requests with full POST bodies + headers, including oneIGDMessageListOffMsysQuerywith itsdoc_id,fb_dtsg,lsdand paginationvariables. The captured body was then used as a template — replacing only thevariables.aftercursor — to drive a full 14-batch pagination loop through the same/api/graphqlendpoint, yielding 286 unique messages (the complete thread history). With only/capture-network, the same extraction would have stopped at the first 20 messages.Also verified:
node --check server.jspassesuserIdtabId