clearcote 0.48.0 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/forms.d.ts +35 -0
- package/dist/forms.d.ts.map +1 -1
- package/dist/forms.js +104 -0
- package/dist/forms.js.map +1 -1
- package/dist/humanize.d.ts +29 -3
- package/dist/humanize.d.ts.map +1 -1
- package/dist/humanize.js +418 -140
- package/dist/humanize.js.map +1 -1
- package/dist/isolated.d.ts +26 -7
- package/dist/isolated.d.ts.map +1 -1
- package/dist/isolated.js +106 -22
- package/dist/isolated.js.map +1 -1
- package/dist/motion.d.ts +42 -1
- package/dist/motion.d.ts.map +1 -1
- package/dist/motion.js +159 -40
- package/dist/motion.js.map +1 -1
- package/dist/pacing.d.ts +56 -0
- package/dist/pacing.d.ts.map +1 -0
- package/dist/pacing.js +203 -0
- package/dist/pacing.js.map +1 -0
- package/package.json +1 -1
package/dist/humanize.js
CHANGED
|
@@ -26,9 +26,11 @@
|
|
|
26
26
|
// ./forms.ts (shared test vectors with Python and .NET), HOW comes from this file. Every DOM read
|
|
27
27
|
// goes through the isolated world (./isolated.ts) or Playwright's utility-world queries, never
|
|
28
28
|
// page.evaluate / locator.evaluate.
|
|
29
|
+
import { errors as pwErrors } from "playwright-core";
|
|
29
30
|
import { makePersona, planMove, dragDwell, clickHold, keyDwell, clickPoint, planAmbient, gaussFrom, rolloverRate, wheelNotches, platformFromNavigator, WHEEL_NOTCH_PX, } from "./motion.js";
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
31
|
+
import { keepInView, trimTo, walk } from "./pacing.js";
|
|
32
|
+
import { COVER_CHECK_MS, COVER_RETRY_MS, ISO_ACTIVE_FIELD, ISO_ACTIVE_VALUE, ISO_CSS_BOX, ISO_DESCRIBE, ISO_FORM_CONTROL, ISO_HIT_IN_BOX, ISO_LISTBOX_ROW, ISO_PLATFORM, ISO_SCROLL_BY, ISO_VIEWPORT, roundHalfEven, worldFor, } from "./isolated.js";
|
|
33
|
+
import { DEFAULT_TYPO_RATE, NEARBY_LETTERS, classifyField, datetimeEqual, enginePathOk, fitMaxlength, formattedEqual, maskKept, matchOption, normalizeClickOptions, numericEqual, planDatetime, planSelect, planTypo, prePressMs, pyFloat, rangeAttrs, rangeFraction, rangeNudge, rangePoint, rangeSanitize, rangeValueOk, segmentNodesFromTree, typoEligible, typosAllowed, } from "./forms.js";
|
|
32
34
|
/** Overlay: a red dot that follows real mousemove events. Idempotent IIFE so it works both as
|
|
33
35
|
* an addInitScript source string AND when passed to page.evaluate. */
|
|
34
36
|
export const CURSOR_OVERLAY = `(() => {
|
|
@@ -55,22 +57,24 @@ const ROLL_KEYS = new Set("abcdefghijklmnopqrstuvwxyz0123456789 ,.;'/-=");
|
|
|
55
57
|
/** The persona platform when the page cannot say: the SDK's default persona follows the host. */
|
|
56
58
|
const hostWheelPlatform = () => process.platform === "win32" ? "windows" : process.platform === "darwin" ? "macos" : "linux";
|
|
57
59
|
// The engine's pointer-path command. r34 renamed Browser.humanizedClick to Browser.dispatchPointerPath:
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
+
// "humanizedClick" is known vocabulary. The served /json/protocol still lists the old name (measured on
|
|
61
|
+
// r38, SC 210); r40 keeps the new one out of it. r33 and older only know the old
|
|
60
62
|
// name, so the new one goes first and the old one is the fallback for a method-not-found only.
|
|
61
63
|
/** @internal exported for tests */
|
|
62
64
|
export const POINTER_PATH = "Browser.dispatchPointerPath";
|
|
63
65
|
/** @internal exported for tests */
|
|
64
66
|
export const POINTER_PATH_LEGACY = "Browser.humanizedClick";
|
|
65
|
-
// Which name each browser answered to, so only its first call can pay for a miss.
|
|
67
|
+
// Which name each browser answered to, so only its first call can pay for a miss. NO_ENGINE_PATH: the
|
|
68
|
+
// engine knew neither name -- remembered too, so a new page of that browser does not probe both again.
|
|
66
69
|
const engineMethodNames = new WeakMap();
|
|
70
|
+
const NO_ENGINE_PATH = "";
|
|
67
71
|
// A CDP method-not-found: the engine predates the name. A raw CDP client sees code -32601;
|
|
68
72
|
// Playwright's CDPSession keeps only the message ("Protocol error (X): 'X' wasn't found").
|
|
69
73
|
const isMethodNotFound = (e) => e?.code === -32601 || /-32601|wasn't found|method not found/i.test(String(e?.message ?? e));
|
|
70
74
|
/** @internal exported for tests. Send `method`, or `legacy` when the engine predates the rename, and
|
|
71
|
-
* remember per `cacheKey` (a Browser, or the context when there is none) which one answered
|
|
72
|
-
*
|
|
73
|
-
* would only replay it. */
|
|
75
|
+
* remember per `cacheKey` (a Browser, or the context when there is none) which one answered -- or that
|
|
76
|
+
* neither did, after which every call fails at once without a round trip. Any error other than
|
|
77
|
+
* method-not-found propagates as-is: retrying a real failure under the old name would only replay it. */
|
|
74
78
|
export async function sendEngineCommand(session, cacheKey, method, legacy, params) {
|
|
75
79
|
let names = engineMethodNames.get(cacheKey);
|
|
76
80
|
if (!names) {
|
|
@@ -78,6 +82,8 @@ export async function sendEngineCommand(session, cacheKey, method, legacy, param
|
|
|
78
82
|
engineMethodNames.set(cacheKey, names);
|
|
79
83
|
}
|
|
80
84
|
const known = names.get(method);
|
|
85
|
+
if (known === NO_ENGINE_PATH)
|
|
86
|
+
throw Object.assign(new Error(`'${method}' wasn't found`), { code: -32601 });
|
|
81
87
|
if (known)
|
|
82
88
|
return session.send(known, params);
|
|
83
89
|
try {
|
|
@@ -89,10 +95,42 @@ export async function sendEngineCommand(session, cacheKey, method, legacy, param
|
|
|
89
95
|
if (!isMethodNotFound(e))
|
|
90
96
|
throw e;
|
|
91
97
|
}
|
|
92
|
-
|
|
98
|
+
let r;
|
|
99
|
+
try {
|
|
100
|
+
r = await session.send(legacy, params);
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
if (isMethodNotFound(e))
|
|
104
|
+
names.set(method, NO_ENGINE_PATH);
|
|
105
|
+
throw e;
|
|
106
|
+
}
|
|
93
107
|
names.set(method, legacy);
|
|
94
108
|
return r;
|
|
95
109
|
}
|
|
110
|
+
/** @internal exported for tests. Did this browser's engine already say it has no pointer-path command? */
|
|
111
|
+
export function engineHasNoPointerPath(cacheKey) {
|
|
112
|
+
return !!cacheKey && engineMethodNames.get(cacheKey)?.get(POINTER_PATH) === NO_ENGINE_PATH;
|
|
113
|
+
}
|
|
114
|
+
/** @internal exported for tests. The page's default action timeout (setDefaultTimeout, else the
|
|
115
|
+
* context's, else Playwright's 30 s). */
|
|
116
|
+
export function defaultTimeoutMs(page) {
|
|
117
|
+
try {
|
|
118
|
+
const t = page?._timeoutSettings?.timeout({});
|
|
119
|
+
if (typeof t === "number" && Number.isFinite(t))
|
|
120
|
+
return t;
|
|
121
|
+
}
|
|
122
|
+
catch { /* not this Playwright's shape */ }
|
|
123
|
+
return 30_000;
|
|
124
|
+
}
|
|
125
|
+
/** @internal exported for tests. A Playwright TimeoutError for a trial whose checks never passed,
|
|
126
|
+
* worded like Playwright's. */
|
|
127
|
+
export function trialTimeoutError(selector, ms, why) {
|
|
128
|
+
const msg = `Timeout ${Math.round(ms)}ms exceeded.\nCall log:\n - waiting for locator('${selector}')\n - ${why}`;
|
|
129
|
+
const E = pwErrors?.TimeoutError;
|
|
130
|
+
if (typeof E === "function")
|
|
131
|
+
return Object.assign(new E(msg), { [SDK_PW_ERROR]: true });
|
|
132
|
+
return Object.assign(new Error(msg), { name: "TimeoutError", [SDK_PW_ERROR]: true });
|
|
133
|
+
}
|
|
96
134
|
// The characters a US-layout keyboard (Playwright's, and the engine's keyboard.getLayoutMap())
|
|
97
135
|
// types with Shift held: capitals and the shifted symbols.
|
|
98
136
|
const SHIFTED_SYMBOLS = new Set('~!@#$%^&*()_+{}|:"<>?');
|
|
@@ -125,6 +163,52 @@ export function typoRate() {
|
|
|
125
163
|
export function isTimeout(err) {
|
|
126
164
|
return err?.name === "TimeoutError";
|
|
127
165
|
}
|
|
166
|
+
// Playwright words every error after the API the caller used: "page.click: Timeout 800ms exceeded.",
|
|
167
|
+
// "locator.check: Error: Not a checkbox or radio button". A humanized call rejected with the error of
|
|
168
|
+
// whatever Playwright call it made inside (locator.waitFor, locator.isChecked; verifier B, SC 210) --
|
|
169
|
+
// the same class, but a message a caller matching on it would not recognise. Pre-existing in 0.48.0.
|
|
170
|
+
const API_PREFIX = /^[A-Za-z_]+\.[A-Za-z_]+: /;
|
|
171
|
+
/** Marks an error the SDK raises in Playwright's place (a trial that never passed, a checkbox that would
|
|
172
|
+
* not change): it gets the caller's API prefix too. */
|
|
173
|
+
const SDK_PW_ERROR = Symbol.for("clearcote.playwrightError");
|
|
174
|
+
/** @internal exported for tests. `err` worded as Playwright words an error of `api` ("page.click",
|
|
175
|
+
* "locator.setChecked"): the inner call's "locator.waitFor: " prefix replaced (or, for an error the SDK
|
|
176
|
+
* raised in Playwright's place, added), in the message and the stack's first line. The object, and so
|
|
177
|
+
* its class, is kept. Anything else is returned as it is. */
|
|
178
|
+
export function asApiError(err, api) {
|
|
179
|
+
const e = err;
|
|
180
|
+
if (!(e instanceof Error) || typeof e.message !== "string")
|
|
181
|
+
return err;
|
|
182
|
+
const m = API_PREFIX.exec(e.message);
|
|
183
|
+
if (!m && !err[SDK_PW_ERROR] && e.name !== "TimeoutError")
|
|
184
|
+
return err;
|
|
185
|
+
const msg = api + ": " + (m ? e.message.slice(m[0].length) : e.message);
|
|
186
|
+
if (msg === e.message)
|
|
187
|
+
return err;
|
|
188
|
+
const old = e.message;
|
|
189
|
+
try {
|
|
190
|
+
e.message = msg;
|
|
191
|
+
if (typeof e.stack === "string")
|
|
192
|
+
e.stack = e.stack.replace(old, msg);
|
|
193
|
+
}
|
|
194
|
+
catch { /* a frozen error keeps its words */ }
|
|
195
|
+
return err;
|
|
196
|
+
}
|
|
197
|
+
/** @internal `fn`, with every Playwright error it rejects with worded as `api`'s (asApiError). */
|
|
198
|
+
export function apiErrors(api, fn) {
|
|
199
|
+
return async (...a) => {
|
|
200
|
+
try {
|
|
201
|
+
return await fn(...a);
|
|
202
|
+
}
|
|
203
|
+
catch (e) {
|
|
204
|
+
throw asApiError(e, api);
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
/** @internal An Error the SDK raises where Playwright would, worded by the caller's API (apiErrors). */
|
|
209
|
+
export function sdkPlaywrightError(message) {
|
|
210
|
+
return Object.assign(new Error(message), { [SDK_PW_ERROR]: true });
|
|
211
|
+
}
|
|
128
212
|
/** Internal: the humane route cannot apply here; run the native Playwright call instead. */
|
|
129
213
|
class NativeSignal extends Error {
|
|
130
214
|
constructor() { super("clearcote humanize: native route"); this.name = "NativeSignal"; }
|
|
@@ -144,6 +228,22 @@ export function createInputLock() {
|
|
|
144
228
|
};
|
|
145
229
|
}
|
|
146
230
|
/** Wrap a single page's input methods + (optionally) inject the cursor overlay. */
|
|
231
|
+
/** Which clicks the engine pointer path (Browser.dispatchPointerPath) may walk, from
|
|
232
|
+
* CLEARCOTE_HUMANIZE_ENGINE_CLICKS: "off" (also 0/false/no) never; "all" (also "selector") coordinate
|
|
233
|
+
* clicks AND targeted page.click(selector) / locator.click() clicks; anything else (the default)
|
|
234
|
+
* coordinate clicks only, as before.
|
|
235
|
+
*
|
|
236
|
+
* Targeted clicks stay on the SDK planner by default (SC 210 lane S, measured on r38): the engine
|
|
237
|
+
* presses 9 ms after its last move (the planner's press waits 40-130 ms), and it walks from its own last
|
|
238
|
+
* position, which an SDK glide does not update. Same values as Python `_engine_clicks_mode`. */
|
|
239
|
+
export function engineClicksMode() {
|
|
240
|
+
const v = (process.env.CLEARCOTE_HUMANIZE_ENGINE_CLICKS || "").trim().toLowerCase();
|
|
241
|
+
if (["0", "off", "false", "no", "none"].includes(v))
|
|
242
|
+
return "off";
|
|
243
|
+
if (["all", "selector", "selectors"].includes(v))
|
|
244
|
+
return "all";
|
|
245
|
+
return "coords";
|
|
246
|
+
}
|
|
147
247
|
export async function attachHumanize(browser, page, opts) {
|
|
148
248
|
// IDEMPOTENCY GUARD — do not remove.
|
|
149
249
|
//
|
|
@@ -193,22 +293,37 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
193
293
|
// Walk a planned trajectory (motion.ts) with native (trusted) mouse.move + off-protocol sleeps.
|
|
194
294
|
// Native input carries the button state, so a button pressed via mouse.down() stays held across
|
|
195
295
|
// the whole path — the same code powers a free move AND a held-button drag.
|
|
296
|
+
//
|
|
297
|
+
// Paced against a WALL CLOCK (pacing.ts): each sample goes out at its planned time and late ones
|
|
298
|
+
// are skipped (never the last, and no skip hops more than 40 px, or 20 px once nothing has been
|
|
299
|
+
// dispatched for 40 ms: MAX_JUMP_PX / IDLE_HOP_PX). "Move, then sleep the planned gap" added the
|
|
300
|
+
// ~27 ms mouse.move round trip to every 6-8 ms sample: a 1.05 s planned glide took 3.5-5.4 s on
|
|
301
|
+
// PRO r38 (SC 209), ambient motion 5.7x as long.
|
|
196
302
|
const dispatch = async (steps) => {
|
|
197
|
-
|
|
303
|
+
const vp = await viewBounds();
|
|
304
|
+
await walk(vp ? keepInView(steps, vp.width, vp.height) : steps, async (x, y) => { await nativeMove(x, y); st.placed = true; });
|
|
305
|
+
};
|
|
306
|
+
// The viewport a planned path is kept inside (keepInView: the planner's overshoot arcs left the window
|
|
307
|
+
// near its edges, verifier B), read in the isolated world at most every 2 s; null when it cannot be read.
|
|
308
|
+
let vb = null;
|
|
309
|
+
const viewBounds = async () => {
|
|
310
|
+
const now = Date.now();
|
|
311
|
+
if (vb === null || now - vb.t > 2000) {
|
|
312
|
+
let vp = null;
|
|
198
313
|
try {
|
|
199
|
-
await
|
|
314
|
+
const wh = await worldFor(page).evaluate(ISO_VIEWPORT);
|
|
315
|
+
if (wh && wh[0] && wh[1])
|
|
316
|
+
vp = { width: Number(wh[0]), height: Number(wh[1]) };
|
|
200
317
|
}
|
|
201
|
-
catch {
|
|
202
|
-
|
|
203
|
-
}
|
|
204
|
-
st.placed = true;
|
|
205
|
-
await sleep(s.sleepMs);
|
|
318
|
+
catch { /* detached / navigating / no CDP: no clamp */ }
|
|
319
|
+
vb = { t: now, vp };
|
|
206
320
|
}
|
|
321
|
+
return vb.vp;
|
|
207
322
|
};
|
|
208
323
|
// Move to (x, y) as a minimum-jerk sum-of-submovements path (primary ballistic + corrective homing)
|
|
209
324
|
// with colored sub-pixel noise and Fitts-scaled duration. `settle` adds a seating jiggle (drag).
|
|
210
325
|
const glide = async (x, y, o = {}) => {
|
|
211
|
-
await dispatch(planMove(st, { x, y }, persona, { settle: o.settle, targetW: o.targetW }));
|
|
326
|
+
await dispatch(planMove(st, { x, y }, persona, { settle: o.settle, targetW: o.targetW, held: o.held }));
|
|
212
327
|
st.x = x;
|
|
213
328
|
st.y = y;
|
|
214
329
|
};
|
|
@@ -217,11 +332,23 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
217
332
|
// recorded human paths for PRO, engine bezier for free, tiered by the engine license gate).
|
|
218
333
|
// Held-button DRAGS stay native; falls back to the SDK bezier if the method is unavailable
|
|
219
334
|
// (older engine / no browser session).
|
|
220
|
-
const eng = { session: null, key: null, tid: null, ok: true };
|
|
335
|
+
const eng = { session: null, key: null, tid: null, ok: true, at: null };
|
|
336
|
+
// The engine walks from ITS OWN last position on this page, not from where an SDK glide left the
|
|
337
|
+
// pointer. Measured on r38 (SC 210): an engine click at (300,300), an SDK glide to (800,550), then
|
|
338
|
+
// mouse.click(400,200) through the engine -- its first sample was at (300,300), a 559 px teleport
|
|
339
|
+
// from where the pointer was. So the engine walks only while nothing else has moved the pointer
|
|
340
|
+
// since its own last walk (before its first walk: while nothing has moved it at all).
|
|
341
|
+
const engineInSync = () => (eng.at === null ? !st.placed : st.x === eng.at.x && st.y === eng.at.y);
|
|
221
342
|
const engineGlide = async (x, y, noClick) => {
|
|
222
|
-
if (!eng.ok)
|
|
343
|
+
if (!eng.ok || !engineInSync())
|
|
223
344
|
return false;
|
|
224
345
|
try {
|
|
346
|
+
// This browser's engine already said it has neither command: no sessions, no probe.
|
|
347
|
+
const ctx0 = page.context();
|
|
348
|
+
if (engineHasNoPointerPath((ctx0.browser ? ctx0.browser() : null) ?? ctx0)) {
|
|
349
|
+
eng.ok = false;
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
225
352
|
if (eng.session === null) {
|
|
226
353
|
const ctx = page.context();
|
|
227
354
|
const tsession = await ctx.newCDPSession(page);
|
|
@@ -234,13 +361,17 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
234
361
|
const dx = x - st.x, dy = y - st.y;
|
|
235
362
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
236
363
|
const dur = Math.min(1.10, Math.max(0.28, 0.30 + dist / 1700)) * (0.85 + 0.30 * Math.random());
|
|
364
|
+
const tSend = Date.now();
|
|
237
365
|
await sendEngineCommand(eng.session, eng.key, POINTER_PATH, POINTER_PATH_LEGACY, { targetId: eng.tid, x, y, duration: dur, noClick });
|
|
238
366
|
// The engine walks the trajectory in the BROWSER process and answers this command before it
|
|
239
367
|
// has finished. Without waiting, a following mouse.down() fires at the cursor's STALE
|
|
240
368
|
// position — measured: a move to (110,58) then a press landed at (981,629), on <body>
|
|
241
369
|
// instead of the target, so every move-then-press sequence (drag, press-and-hold,
|
|
242
370
|
// drag-and-drop) acted on the wrong element.
|
|
243
|
-
|
|
371
|
+
// r38 answers only after the walk AND the click (send returned 772 ms after a 600 ms walk, 2 ms
|
|
372
|
+
// after the release, SC 210): wait only for what is left of `dur`, so an engine that answers
|
|
373
|
+
// late does not add a second, idle `dur` after every click.
|
|
374
|
+
await sleep(Math.max(0, dur * 1000 - (Date.now() - tSend)));
|
|
244
375
|
try {
|
|
245
376
|
await nativeMove(x, y);
|
|
246
377
|
}
|
|
@@ -248,6 +379,7 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
248
379
|
st.x = x;
|
|
249
380
|
st.y = y;
|
|
250
381
|
st.placed = true;
|
|
382
|
+
eng.at = { x, y };
|
|
251
383
|
return true;
|
|
252
384
|
}
|
|
253
385
|
catch {
|
|
@@ -283,7 +415,9 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
283
415
|
// an action needs one but names no coordinates.
|
|
284
416
|
const ambient = async (ms) => {
|
|
285
417
|
try {
|
|
286
|
-
|
|
418
|
+
// planAmbient overruns its budget (whole excursions, rests drawn at 0.9-1.4x a frame): cut the
|
|
419
|
+
// plan at a slow sample near ms so ambientMotion(ms) lasts about ms.
|
|
420
|
+
const steps = trimTo(planAmbient(st, await viewport(), persona, ms), ms);
|
|
287
421
|
await dispatch(steps);
|
|
288
422
|
if (steps.length) {
|
|
289
423
|
st.x = steps[steps.length - 1].x;
|
|
@@ -328,7 +462,9 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
328
462
|
// mouse.click keeps engine routing: it performs move+click entirely engine-side, where nothing
|
|
329
463
|
// races the trajectory. glide() is still fully humanized (minimum-jerk, colored noise, Fitts).
|
|
330
464
|
mouse.move = async (x, y) => {
|
|
331
|
-
|
|
465
|
+
// A move while the button is held is a DRAG leg: planDrag keeps it on the line between grab and
|
|
466
|
+
// drop (an aimed reach's arc took a slider handle 47 px off a 40 px track, SC 209).
|
|
467
|
+
await glide(x, y, { held: st.held });
|
|
332
468
|
};
|
|
333
469
|
// down/up are wrapped to TRACK the held state mouse.move needs, and — critically — to make the
|
|
334
470
|
// press position DETERMINISTIC. The engine answers its pointer path before the trajectory has
|
|
@@ -336,6 +472,29 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
336
472
|
// enough: measured on Node, a move to (110,58) was still mid-flight at (416,504) when the press
|
|
337
473
|
// fired, landing on <body>. Re-issuing a native move to the tracked target immediately before
|
|
338
474
|
// the press pins the cursor to where the caller asked, whatever the trajectory is doing.
|
|
475
|
+
// The pause between the pointer coming to rest and the button going down (forms.prePressMs: 40-250
|
|
476
|
+
// ms, right-skewed, centred per identity). Measured on r38 before it: the focus click before typing
|
|
477
|
+
// pressed ~4 ms after the last pointermove, a drag grab ~21 ms.
|
|
478
|
+
const prePress = async () => { await sleep(prePressMs(persona.seed)); };
|
|
479
|
+
// What is left of a button hold once mouse.down() has returned: the hold runs from when the press was
|
|
480
|
+
// SENT. mouse.down() returns only after the page handled the press, and a <select> opens its popup
|
|
481
|
+
// inside that, so a hold slept afterwards reached the page ~30-80 ms longer than planned.
|
|
482
|
+
const holdRest = (holdMs, t0) => Math.max(0, holdMs - (Date.now() - t0));
|
|
483
|
+
// Press and release the left button where the last glide left the pointer, after the pre-press
|
|
484
|
+
// pause. Native down/up, so nothing moves: Playwright's mouse.click(x, y) sends one more mouseMoved
|
|
485
|
+
// first, and THAT was the last pointermove the page saw, ~4 ms before the press.
|
|
486
|
+
const pressHere = async (x, y, holdMs) => {
|
|
487
|
+
const hold = holdMs ?? clickHold(persona);
|
|
488
|
+
await prePress();
|
|
489
|
+
const t0 = Date.now();
|
|
490
|
+
await nativeDown();
|
|
491
|
+
try {
|
|
492
|
+
await sleep(holdRest(hold, t0));
|
|
493
|
+
}
|
|
494
|
+
finally {
|
|
495
|
+
await nativeUp();
|
|
496
|
+
} // never leave the button down
|
|
497
|
+
};
|
|
339
498
|
mouse.down = async (...a) => {
|
|
340
499
|
// DELIBERATELY NO PLACEMENT WHEN THE POINTER HAS NEVER MOVED.
|
|
341
500
|
//
|
|
@@ -354,6 +513,9 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
354
513
|
await nativeMove(st.x, st.y);
|
|
355
514
|
}
|
|
356
515
|
catch { /* best effort: keep the press on target */ }
|
|
516
|
+
// The hand has arrived; it confirms the target before the button goes down. The pin is itself
|
|
517
|
+
// a pointermove, so the pause comes after it. A down() with no move before it stays instant.
|
|
518
|
+
await prePress();
|
|
357
519
|
}
|
|
358
520
|
st.held = true;
|
|
359
521
|
try {
|
|
@@ -375,12 +537,12 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
375
537
|
// dropped those options and always left-clicked once.
|
|
376
538
|
const hclick = async (x, y, options = {}) => {
|
|
377
539
|
const o = normalizeClickOptions(options);
|
|
378
|
-
if (enginePathOk(o) && await engineGlide(x, y, false))
|
|
540
|
+
if (enginePathOk(o) && engineClicksMode() !== "off" && await engineGlide(x, y, false))
|
|
379
541
|
return;
|
|
380
542
|
await glide(x, y);
|
|
381
543
|
try {
|
|
382
544
|
if (o.modifiers.length && !(nativeKbDown && nativeKbUp)) {
|
|
383
|
-
await
|
|
545
|
+
await prePress(); // the hand confirms the target before it presses
|
|
384
546
|
// delay = the mousedown→mouseup HOLD (human ~60–150 ms); without it Playwright releases in ~2 ms.
|
|
385
547
|
await nativeClick(x, y, { delay: o.delay ?? clickHold(persona), button: o.button, clickCount: o.clickCount });
|
|
386
548
|
return;
|
|
@@ -499,6 +661,8 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
499
661
|
const keyboard = page.keyboard;
|
|
500
662
|
const nativeKbType = keyboard.type.bind(keyboard);
|
|
501
663
|
const nativeKbPress = keyboard.press.bind(keyboard);
|
|
664
|
+
// A bulk fill's paste (Input.insertText).
|
|
665
|
+
const nativeKbInsert = typeof keyboard.insertText === "function" ? keyboard.insertText.bind(keyboard) : undefined;
|
|
502
666
|
// A page-like keyboard without down/up still gets every other humanization; it just types
|
|
503
667
|
// capitals without the Shift key, as before.
|
|
504
668
|
const nativeKbDown = typeof keyboard.down === "function" ? keyboard.down.bind(keyboard) : undefined;
|
|
@@ -527,8 +691,8 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
527
691
|
};
|
|
528
692
|
// Press at the point the last glide ended, the way the options ask: the button, the click count (a
|
|
529
693
|
// double click is two presses with a human gap, not one event), the caller's hold (`delay`) or the
|
|
530
|
-
// persona's, and modifiers held around the whole press with a lead before it (80-
|
|
531
|
-
// per modifier plus the 40-
|
|
694
|
+
// persona's, and modifiers held around the whole press with a lead before it (80-360 ms: 40-110 ms
|
|
695
|
+
// per modifier plus the 40-250 ms pre-press pause) and a 20-80 ms lag after, as fingers do. Native
|
|
532
696
|
// down/up, so nothing moves.
|
|
533
697
|
const pressAt = async (o) => {
|
|
534
698
|
const held = [];
|
|
@@ -538,13 +702,14 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
538
702
|
held.push(m);
|
|
539
703
|
await sleep(rand(40, 110));
|
|
540
704
|
}
|
|
541
|
-
await
|
|
705
|
+
await prePress(); // the hand confirms the target before it presses
|
|
542
706
|
const n = o.clickCount;
|
|
543
707
|
for (let i = 1; i <= n; i++) {
|
|
544
708
|
const hold = o.delay ?? clickHold(persona);
|
|
709
|
+
const t0 = Date.now();
|
|
545
710
|
await nativeDown({ button: o.button, clickCount: i });
|
|
546
711
|
try {
|
|
547
|
-
await sleep(hold);
|
|
712
|
+
await sleep(holdRest(hold, t0));
|
|
548
713
|
}
|
|
549
714
|
finally {
|
|
550
715
|
await nativeUp({ button: o.button, clickCount: i }); // never leave the button down
|
|
@@ -862,18 +1027,24 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
862
1027
|
return width > 0 && height > 0 ? { x, y, width, height } : null;
|
|
863
1028
|
};
|
|
864
1029
|
// Does a press at viewport (x, y) reach the element filling `box`? true / false, or null when it
|
|
865
|
-
// cannot be told here
|
|
866
|
-
|
|
1030
|
+
// cannot be told here (CDP is unavailable). A point inside a child frame is answered by the <iframe>
|
|
1031
|
+
// element that holds it (IsolatedWorld.nodeAt).
|
|
1032
|
+
// `sel`: a plain-CSS selector for the element, when the caller has one: whether the hit is THAT
|
|
1033
|
+
// element (ISO_HIT_IN_BOX), not just one that fills the box.
|
|
1034
|
+
const hitInBox = async (box, x, y, sel = null) => {
|
|
867
1035
|
const w = worldFor(page);
|
|
868
1036
|
const nid = await w.nodeAt(x, y);
|
|
869
1037
|
if (nid === undefined)
|
|
870
1038
|
return null;
|
|
871
|
-
const
|
|
1039
|
+
const b = { x: box.x, y: box.y, width: box.width, height: box.height };
|
|
1040
|
+
if (sel)
|
|
1041
|
+
b.sel = sel;
|
|
1042
|
+
const res = await w.callOnNode(nid, ISO_HIT_IN_BOX, b);
|
|
872
1043
|
return res === undefined || res === null ? null : Boolean(res);
|
|
873
1044
|
};
|
|
874
1045
|
// Does the element filling `box` own its centre -- not clipped by a scrolling container, not
|
|
875
1046
|
// covered? The browser's own hit test, read in the isolated world.
|
|
876
|
-
const hitOk = async (box) => (await hitInBox(box, box.x + box.width / 2, box.y + box.height / 2)) === true;
|
|
1047
|
+
const hitOk = async (box, sel = null) => (await hitInBox(box, box.x + box.width / 2, box.y + box.height / 2, sel)) === true;
|
|
877
1048
|
// The box of a plain-CSS selector's first match, read (and scrolled into view) in the isolated
|
|
878
1049
|
// world: not even the one boundingBox() handle, and its textContent preview, is made. Used only
|
|
879
1050
|
// when Playwright counts exactly the elements the browser's own querySelectorAll finds --
|
|
@@ -894,7 +1065,7 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
894
1065
|
return null;
|
|
895
1066
|
}
|
|
896
1067
|
let box = rectBox(r);
|
|
897
|
-
if (box && !((await inView(box)) && (await hitOk(box)))) {
|
|
1068
|
+
if (box && !((await inView(box)) && (await hitOk(box, selector)))) {
|
|
898
1069
|
// scrollIntoViewIfNeeded also scrolls a scrolling container that clips it, and does nothing
|
|
899
1070
|
// when it already shows (then the cover is an overlay: the click's own check)
|
|
900
1071
|
box = rectBox(await w.evaluate(ISO_CSS_BOX, { sel: selector, scroll: true }));
|
|
@@ -938,12 +1109,12 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
938
1109
|
if (css)
|
|
939
1110
|
return css;
|
|
940
1111
|
let box = await loc.boundingBox();
|
|
941
|
-
if (box && !((await inView(box)) && (await hitOk(box)))) {
|
|
1112
|
+
if (box && !((await inView(box)) && (await hitOk(box, selector)))) {
|
|
942
1113
|
// Off-screen: scroll the window to it and prove it shows there (hit test), with no new handle.
|
|
943
1114
|
// Clipped by a scrolling container, covered, or the window scroll did not bring it out:
|
|
944
1115
|
// Playwright's scroll, which also scrolls containers, and a fresh box.
|
|
945
1116
|
const moved = !(await inView(box)) ? await scrollWindowTo(box) : null;
|
|
946
|
-
if (moved && (await inView(moved)) && (await hitOk(moved)))
|
|
1117
|
+
if (moved && (await inView(moved)) && (await hitOk(moved, selector)))
|
|
947
1118
|
return moved;
|
|
948
1119
|
await loc.scrollIntoViewIfNeeded({ timeout });
|
|
949
1120
|
box = await loc.boundingBox();
|
|
@@ -1002,10 +1173,10 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
1002
1173
|
else if (info.disabled) {
|
|
1003
1174
|
return false;
|
|
1004
1175
|
}
|
|
1005
|
-
const
|
|
1176
|
+
const p0 = at ? { x: at[0], y: at[1] } : clickPoint(box, st, persona); // 2D gaussian toward the centre
|
|
1177
|
+
const pt = { x: roundHalfEven(p0.x), y: roundHalfEven(p0.y) }; // the pixel the hit test answered for
|
|
1006
1178
|
await glide(pt.x, pt.y);
|
|
1007
|
-
await
|
|
1008
|
-
await nativeClick(pt.x, pt.y, { delay: clickHold(persona) });
|
|
1179
|
+
await pressHere(pt.x, pt.y);
|
|
1009
1180
|
return true;
|
|
1010
1181
|
};
|
|
1011
1182
|
// Ctrl+A (Cmd+A on a Mac engine) the way a hand does it: the modifier goes down a moment before the
|
|
@@ -1083,16 +1254,30 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
1083
1254
|
// outside the try: a native call that raises must raise ONCE, not be retried by the handler
|
|
1084
1255
|
return nativeType(selector, text, options);
|
|
1085
1256
|
};
|
|
1086
|
-
page.type = (selector, text, options = {}) => locked(() => htype(selector, text, options));
|
|
1257
|
+
page.type = apiErrors("page.type", (selector, text, options = {}) => locked(() => htype(selector, text, options)));
|
|
1087
1258
|
// Type `value` into a text control and PROVE it landed. The value is read through Playwright's
|
|
1088
1259
|
// utility world (`inputValue`): it sees the control the locator means, wherever focus is, and the page
|
|
1089
1260
|
// cannot observe it. A field whose value cannot be read, or a click that did not put the caret in it
|
|
1090
1261
|
// (an overlay took it), is left to Playwright -- "could not check" is never treated as success.
|
|
1091
1262
|
const fillText = async (loc, box, value, info, selector = null) => {
|
|
1092
|
-
if (Array.from(value).length > 200)
|
|
1093
|
-
return false; // bulk values: keep fill fast/atomic
|
|
1094
1263
|
const isCe = Boolean(info && info.ce)
|
|
1095
1264
|
|| (info === null && ((await loc.getAttribute("contenteditable")) || "false") !== "false");
|
|
1265
|
+
// What the field can hold: a maxlength keeps the first N characters (what Playwright's insertText
|
|
1266
|
+
// leaves too), and a person types only those -- the field stops taking keys.
|
|
1267
|
+
let want = value;
|
|
1268
|
+
let limit = [null, null, null];
|
|
1269
|
+
if (!isCe) {
|
|
1270
|
+
limit = info
|
|
1271
|
+
? [info.maxlength, info.tag, info.type]
|
|
1272
|
+
: [await loc.getAttribute("maxlength"),
|
|
1273
|
+
(await loc.locator("xpath=self::textarea").count()) ? "textarea" : "input", (await loc.getAttribute("type")) || ""];
|
|
1274
|
+
want = fitMaxlength(value, ...limit);
|
|
1275
|
+
}
|
|
1276
|
+
// Bulk values (over 200 characters) are still inserted at once -- typing thousands of keys would
|
|
1277
|
+
// crawl -- but the way a person pastes: glide to the field, click into it, then insert.
|
|
1278
|
+
const bulk = Array.from(want).length > 200; // what the field holds: a 300-character value cut to 5 is typed
|
|
1279
|
+
if (bulk && !nativeKbInsert)
|
|
1280
|
+
return false;
|
|
1096
1281
|
if (!(await focusClick(box, box ? provenPoint(box, info) : null, loc, selector)))
|
|
1097
1282
|
return false;
|
|
1098
1283
|
await sleep(rand(40, 120));
|
|
@@ -1106,11 +1291,20 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
1106
1291
|
return null;
|
|
1107
1292
|
}
|
|
1108
1293
|
};
|
|
1294
|
+
const put = async (text, typos = true) => {
|
|
1295
|
+
if (bulk) {
|
|
1296
|
+
await sleep(rand(120, 320)); // the paste shortcut, after the click
|
|
1297
|
+
await nativeKbInsert(text);
|
|
1298
|
+
}
|
|
1299
|
+
else {
|
|
1300
|
+
await humanTypeText(text, info, typos);
|
|
1301
|
+
}
|
|
1302
|
+
};
|
|
1109
1303
|
if (isCe) {
|
|
1110
1304
|
const cur = info ? info.value : null;
|
|
1111
1305
|
if (cur)
|
|
1112
1306
|
await clearFocused();
|
|
1113
|
-
await
|
|
1307
|
+
await put(want);
|
|
1114
1308
|
return true; // contenteditable: Playwright's fill sets text the same way; no value to read
|
|
1115
1309
|
}
|
|
1116
1310
|
const cur = await read();
|
|
@@ -1118,18 +1312,22 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
1118
1312
|
return false;
|
|
1119
1313
|
if (cur)
|
|
1120
1314
|
await clearFocused(read); // a person does not select-all an empty field
|
|
1121
|
-
await
|
|
1315
|
+
await put(want);
|
|
1122
1316
|
let got = await read();
|
|
1123
1317
|
if (got === null)
|
|
1124
1318
|
return false;
|
|
1125
|
-
|
|
1126
|
-
|
|
1319
|
+
// exact; a mask that only re-punctuated what was typed (a phone format); or one that re-punctuated it
|
|
1320
|
+
// and then stopped taking keys (MM/YY on maxlength 5): no wipe and retype (maskKept)
|
|
1321
|
+
if (got === want || formattedEqual(got, want) || maskKept(got, want, ...limit))
|
|
1322
|
+
return true;
|
|
1323
|
+
if (bulk)
|
|
1324
|
+
return false; // an inserted value that did not land: Playwright's fill, not a second paste
|
|
1127
1325
|
// Wrong value (an autocompleter interfered, a key was lost): one clean retry without slips, then
|
|
1128
1326
|
// give the field to Playwright -- a correct value beats pretty input.
|
|
1129
1327
|
await clearFocused(read);
|
|
1130
|
-
await
|
|
1328
|
+
await put(want, false);
|
|
1131
1329
|
got = await read();
|
|
1132
|
-
return got !== null && (got ===
|
|
1330
|
+
return got !== null && (got === want || formattedEqual(got, want) || maskKept(got, want, ...limit));
|
|
1133
1331
|
};
|
|
1134
1332
|
// A range input: drag the thumb (or click the track) to the value, then arrow-nudge to it exactly.
|
|
1135
1333
|
// Typing digits into a slider -- what fill() did before -- changes nothing, and the click before it
|
|
@@ -1162,7 +1360,7 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
1162
1360
|
let [cx, cy] = rangePoint(box, rangeFraction(cur, lo, hi), rtl);
|
|
1163
1361
|
cy += gauss() * box.height * 0.08;
|
|
1164
1362
|
await glide(cx, cy, { targetW: 16 });
|
|
1165
|
-
await
|
|
1363
|
+
await prePress();
|
|
1166
1364
|
const { grabMs, releaseMs } = dragDwell(persona);
|
|
1167
1365
|
await nativeDown();
|
|
1168
1366
|
st.held = true;
|
|
@@ -1178,8 +1376,7 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
1178
1376
|
}
|
|
1179
1377
|
else {
|
|
1180
1378
|
await glide(tx, ty, { targetW: 16 });
|
|
1181
|
-
await
|
|
1182
|
-
await nativeClick(tx, ty, { delay: clickHold(persona) });
|
|
1379
|
+
await pressHere(tx, ty);
|
|
1183
1380
|
}
|
|
1184
1381
|
for (let round = 0; round < 3; round++) {
|
|
1185
1382
|
const v = await now();
|
|
@@ -1232,17 +1429,15 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
1232
1429
|
py = box.y + box.height / 2;
|
|
1233
1430
|
}
|
|
1234
1431
|
await glide(px, py, { targetW: fw });
|
|
1235
|
-
await
|
|
1236
|
-
await nativeClick(px, py, { delay: clickHold(persona) });
|
|
1432
|
+
await pressHere(px, py);
|
|
1237
1433
|
await sleep(rand(150, 350));
|
|
1238
1434
|
for (const a of plan) {
|
|
1239
1435
|
if (a[0] === "type") {
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
}
|
|
1436
|
+
// The same typing model as text (persona dwell, gaussian gaps, thinking pauses, rollover): the
|
|
1437
|
+
// old flat 60-150 ms gap had a CV of 0.24 and no rollover. A segment moves on by itself after
|
|
1438
|
+
// its last digit and keys go down in order, so a rolled-over digit still lands in the right
|
|
1439
|
+
// field (checked on r38, all five types). No typos: a slip in a date is a wrong date.
|
|
1440
|
+
await humanTypeText(a[1], null, false);
|
|
1246
1441
|
}
|
|
1247
1442
|
else {
|
|
1248
1443
|
await key(a[1], a.length > 2 ? a[2] : 1, [70, 170]);
|
|
@@ -1292,13 +1487,13 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
1292
1487
|
// raises, as before humanize), and anything the humane route could not prove it did right.
|
|
1293
1488
|
return nativePageFill(selector, value, options);
|
|
1294
1489
|
};
|
|
1295
|
-
page.fill = (selector, value, options = {}) => locked(() => hfill(selector, value, options));
|
|
1296
|
-
page.dblclick = (selector, options = {}) => locked(() => {
|
|
1490
|
+
page.fill = apiErrors("page.fill", (selector, value, options = {}) => locked(() => hfill(selector, value, options)));
|
|
1491
|
+
page.dblclick = apiErrors("page.dblclick", (selector, options = {}) => locked(() => {
|
|
1297
1492
|
const o = { ...(options ?? {}) };
|
|
1298
1493
|
if (o.clickCount === undefined)
|
|
1299
1494
|
o.clickCount = 2;
|
|
1300
1495
|
return targetedClick(nativePageDblclick, selector, o, false, options ?? {});
|
|
1301
|
-
});
|
|
1496
|
+
}));
|
|
1302
1497
|
const hpress = async (selector, key, options) => {
|
|
1303
1498
|
try {
|
|
1304
1499
|
// Click into text fields only: a press on a button or a link that is not focused yet is
|
|
@@ -1316,7 +1511,7 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
1316
1511
|
}
|
|
1317
1512
|
return nativePagePress(selector, key, options);
|
|
1318
1513
|
};
|
|
1319
|
-
page.press = (selector, key, options = {}) => locked(() => hpress(selector, key, options));
|
|
1514
|
+
page.press = apiErrors("page.press", (selector, key, options = {}) => locked(() => hpress(selector, key, options)));
|
|
1320
1515
|
// ------------------------------------------------------------------------- selectOption
|
|
1321
1516
|
// Open the drop-down with a click, move its highlight with the planned keys, commit with Enter: one
|
|
1322
1517
|
// trusted input+change, the sequence a mouse-and-keyboard user produces. While the popup is open its
|
|
@@ -1465,8 +1660,7 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
1465
1660
|
await pressAt(normalizeClickOptions({ modifiers: ["ControlOrMeta"] }));
|
|
1466
1661
|
}
|
|
1467
1662
|
else {
|
|
1468
|
-
await
|
|
1469
|
-
await nativeClick(px, py, { delay: clickHold(persona) });
|
|
1663
|
+
await pressHere(px, py);
|
|
1470
1664
|
}
|
|
1471
1665
|
await sleep(rand(150, 350));
|
|
1472
1666
|
}
|
|
@@ -1537,35 +1731,39 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
1537
1731
|
}
|
|
1538
1732
|
return nativePageSelect(selector, values, options);
|
|
1539
1733
|
};
|
|
1540
|
-
page.selectOption = (selector, values, options = {}) => locked(() => hselect(selector, values, options));
|
|
1734
|
+
page.selectOption = apiErrors("page.selectOption", (selector, values, options = {}) => locked(() => hselect(selector, values, options)));
|
|
1541
1735
|
}
|
|
1542
1736
|
// ------------------------------------------------------------------ check / uncheck / setChecked
|
|
1543
1737
|
// A humanized click when the state must change, then the state verified. `trial` performs no action:
|
|
1544
|
-
//
|
|
1738
|
+
// the state is read (a non-checkbox raises, as in Playwright) and, when a click would be needed, its
|
|
1739
|
+
// checks run here (trial) -- not Playwright's trial, which moves the pointer and presses. Errors propagate as Playwright's own (not a checkbox, timed out): the
|
|
1545
1740
|
// click owns its native fallback, so nothing here may click a second time -- and when one click did
|
|
1546
1741
|
// not change the state (a page that cancels it), this raises like Playwright's check instead of
|
|
1547
1742
|
// clicking again.
|
|
1548
1743
|
const setChecked = async (orig, selector, checked, options) => {
|
|
1549
1744
|
const opts = options ?? {};
|
|
1550
|
-
if (opts.trial)
|
|
1551
|
-
return orig(selector, opts);
|
|
1552
1745
|
const loc = page.locator(selector).first();
|
|
1746
|
+
if (opts.trial) {
|
|
1747
|
+
if ((await loc.isChecked({ timeout: opts.timeout })) === checked)
|
|
1748
|
+
return;
|
|
1749
|
+
return trial(selector, opts, false);
|
|
1750
|
+
}
|
|
1553
1751
|
if ((await loc.isChecked({ timeout: opts.timeout })) === checked)
|
|
1554
1752
|
return;
|
|
1555
1753
|
await targetedClick(nativePageClick, selector, { ...opts }, false, null);
|
|
1556
1754
|
if ((await loc.isChecked()) === checked)
|
|
1557
1755
|
return;
|
|
1558
|
-
throw
|
|
1756
|
+
throw sdkPlaywrightError("Clicking the checkbox did not change its state");
|
|
1559
1757
|
};
|
|
1560
1758
|
for (const [name, want] of [["check", true], ["uncheck", false]]) {
|
|
1561
1759
|
if (typeof page[name] === "function") {
|
|
1562
1760
|
const orig = page[name].bind(page);
|
|
1563
|
-
page[name] = (selector, options = {}) => locked(() => setChecked(orig, selector, want, options));
|
|
1761
|
+
page[name] = apiErrors("page." + name, (selector, options = {}) => locked(() => setChecked(orig, selector, want, options)));
|
|
1564
1762
|
}
|
|
1565
1763
|
}
|
|
1566
1764
|
if (typeof page.setChecked === "function") {
|
|
1567
1765
|
const origSc = page.setChecked.bind(page);
|
|
1568
|
-
page.setChecked = (selector, checked, options = {}) => locked(() => setChecked((s, o) => origSc(s, checked, o), selector, Boolean(checked), options));
|
|
1766
|
+
page.setChecked = apiErrors("page.setChecked", (selector, checked, options = {}) => locked(() => setChecked((s, o) => origSc(s, checked, o), selector, Boolean(checked), options)));
|
|
1569
1767
|
}
|
|
1570
1768
|
// ------------------------------------------------------------------------- click / hover
|
|
1571
1769
|
// The covered-point check, through the browser's own hit test read in the isolated world: nothing is
|
|
@@ -1574,35 +1772,61 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
1574
1772
|
// saw, before every humanized click since 0.46.0, a mousemove inside the target, a 450-500 px jump
|
|
1575
1773
|
// back, then the glide, and a pointerdown/pointerup/click (left, even for a right click) that window
|
|
1576
1774
|
// capture listeners could see. A covered point is looked at again for COVER_CHECK_MS (an overlay
|
|
1577
|
-
// fading out).
|
|
1578
|
-
|
|
1775
|
+
// fading out). A point inside a child frame is answered by the <iframe> element that holds it
|
|
1776
|
+
// (IsolatedWorld.nodeAt), so no Playwright trial runs for it either. false when covered; null when
|
|
1777
|
+
// the hit test cannot run (no CDP): the caller goes native.
|
|
1778
|
+
const reachesPoint = async (box, x, y, sel = null) => {
|
|
1579
1779
|
const deadline = Date.now() + COVER_CHECK_MS;
|
|
1580
|
-
let hit = await hitInBox(box, x, y);
|
|
1780
|
+
let hit = await hitInBox(box, x, y, sel);
|
|
1581
1781
|
while (hit === false && Date.now() < deadline) {
|
|
1582
1782
|
await sleep(80);
|
|
1583
|
-
hit = await hitInBox(box, x, y);
|
|
1584
|
-
}
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
const
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1783
|
+
hit = await hitInBox(box, x, y, sel);
|
|
1784
|
+
}
|
|
1785
|
+
return hit;
|
|
1786
|
+
};
|
|
1787
|
+
// The viewport point of Playwright's `position` (relative to the PADDING box: the border widths are added).
|
|
1788
|
+
const positionPoint = async (box, pos) => {
|
|
1789
|
+
const info = await describe(box, null);
|
|
1790
|
+
const bl = info ? info.bl || 0 : 0, bt = info ? info.bt || 0 : 0;
|
|
1791
|
+
return { x: box.x + bl + pos.x, y: box.y + bt + pos.y };
|
|
1792
|
+
};
|
|
1793
|
+
// `trial: true`, answered by the SDK: Playwright's actionability checks -- visible, enabled (not for
|
|
1794
|
+
// hover), stable, and the click point receives the press (the browser's own hit test, read in the
|
|
1795
|
+
// isolated world) -- retried until the caller's timeout (or the page default), then a Playwright
|
|
1796
|
+
// TimeoutError. NO pointer move and NO press: Playwright's own trial moves the real pointer onto the
|
|
1797
|
+
// target and dispatches pointerdown/pointerup/click that page listeners registered before its blocker
|
|
1798
|
+
// see (SC 209: 6/6). The point checked is Playwright's: `position`, else the centre. Scrolling the
|
|
1799
|
+
// element into view is part of the checks, as in Playwright. With `force` only the element must exist.
|
|
1800
|
+
const trial = async (selector, options, noClick) => {
|
|
1801
|
+
const opts = options ?? {};
|
|
1802
|
+
const o = normalizeClickOptions(opts);
|
|
1803
|
+
const ms = typeof opts.timeout === "number" ? opts.timeout : defaultTimeoutMs(page);
|
|
1804
|
+
const end = ms > 0 ? Date.now() + ms : null;
|
|
1805
|
+
const left = () => (end === null ? 0 : Math.max(1, Math.round(end - Date.now())));
|
|
1806
|
+
const loc = page.locator(selector).first();
|
|
1807
|
+
if (o.force) {
|
|
1808
|
+
await loc.waitFor({ state: "attached", timeout: left() });
|
|
1809
|
+
return;
|
|
1603
1810
|
}
|
|
1604
|
-
|
|
1605
|
-
|
|
1811
|
+
for (;;) {
|
|
1812
|
+
let why = "element is not visible";
|
|
1813
|
+
const box = await boxOf(loc, left(), false, selector); // waits for visible; throws Playwright's timeout
|
|
1814
|
+
if (box) {
|
|
1815
|
+
if (!noClick && !(await loc.isEnabled())) {
|
|
1816
|
+
why = "element is not enabled";
|
|
1817
|
+
}
|
|
1818
|
+
else {
|
|
1819
|
+
const { x, y } = o.position ? await positionPoint(box, o.position)
|
|
1820
|
+
: { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
|
1821
|
+
await sleep(50); // stable: 50 ms on, the element still fills `box` (ISO_HIT_IN_BOX)
|
|
1822
|
+
if ((await hitInBox(box, x, y, selector)) !== false)
|
|
1823
|
+
return; // null: the hit test cannot run -- nothing to add
|
|
1824
|
+
why = `element is not stable, or another element would receive the pointer at (${Math.round(x)}, ${Math.round(y)})`;
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
if (end !== null && Date.now() >= end)
|
|
1828
|
+
throw trialTimeoutError(selector, ms, why);
|
|
1829
|
+
await sleep(end === null ? 100 : Math.max(0, Math.min(100, end - Date.now())));
|
|
1606
1830
|
}
|
|
1607
1831
|
};
|
|
1608
1832
|
// The humanized form of page.click / dblclick / hover (and the click inside check/uncheck and the
|
|
@@ -1610,12 +1834,13 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
1610
1834
|
//
|
|
1611
1835
|
// Every Playwright option is honoured: `button`, `clickCount`, `delay` (the hold) and `modifiers`
|
|
1612
1836
|
// shape the press; `position` replaces the persona's point; `force` skips the covered-point check
|
|
1613
|
-
// (and, as in Playwright, every wait); `trial` performs NO action
|
|
1614
|
-
//
|
|
1615
|
-
//
|
|
1837
|
+
// (and, as in Playwright, every wait); `trial` performs NO action: its checks run here (trial), with
|
|
1838
|
+
// no pointer move and no press. The caller's `timeout` is passed through unchanged. No Playwright
|
|
1839
|
+
// trial runs before a humanized press either (reachesPoint).
|
|
1616
1840
|
const targetedClick = async (orig, selector, options, noClick, nativeOpts) => {
|
|
1617
1841
|
const opts = options ?? {};
|
|
1618
1842
|
const o = normalizeClickOptions(opts);
|
|
1843
|
+
let aim = null; // where Playwright's own click lands, once the box is known (native)
|
|
1619
1844
|
const native = async () => {
|
|
1620
1845
|
if (!orig)
|
|
1621
1846
|
throw new Error("humanized click could not run");
|
|
@@ -1624,12 +1849,19 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
1624
1849
|
}
|
|
1625
1850
|
finally {
|
|
1626
1851
|
// Native click/hover genuinely move the browser pointer to the element. Leaving `placed`
|
|
1627
|
-
// false afterwards made the next wheel anchor believe the pointer was still unplaced
|
|
1852
|
+
// false afterwards made the next wheel anchor believe the pointer was still unplaced -- and the
|
|
1853
|
+
// engine path walked again from its own stale point (engineInSync; verifier A #6). The next
|
|
1854
|
+
// SDK glide starts where Playwright pressed: `position`, else the centre of the box.
|
|
1628
1855
|
st.placed = true;
|
|
1856
|
+
eng.at = null;
|
|
1857
|
+
if (aim) {
|
|
1858
|
+
st.x = aim.x;
|
|
1859
|
+
st.y = aim.y;
|
|
1860
|
+
}
|
|
1629
1861
|
}
|
|
1630
1862
|
};
|
|
1631
1863
|
if (o.trial)
|
|
1632
|
-
return
|
|
1864
|
+
return trial(selector, opts, noClick);
|
|
1633
1865
|
try {
|
|
1634
1866
|
const timeout = opts.timeout; // undefined: the page's default, as Playwright would use
|
|
1635
1867
|
const loc = page.locator(selector).first();
|
|
@@ -1638,29 +1870,38 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
1638
1870
|
const box = await boxOf(loc, timeout, o.force, selector);
|
|
1639
1871
|
if (!box)
|
|
1640
1872
|
throw new NativeSignal();
|
|
1873
|
+
aim = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
|
1641
1874
|
if (!o.force && !(await loc.isEnabled()))
|
|
1642
1875
|
throw new NativeSignal();
|
|
1643
1876
|
let x, y;
|
|
1644
1877
|
if (o.position) {
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
const bl = info ? info.bl || 0 : 0, bt = info ? info.bt || 0 : 0;
|
|
1648
|
-
x = box.x + bl + o.position.x;
|
|
1649
|
-
y = box.y + bt + o.position.y;
|
|
1878
|
+
({ x, y } = await positionPoint(box, o.position));
|
|
1879
|
+
aim = { x, y };
|
|
1650
1880
|
}
|
|
1651
1881
|
else {
|
|
1652
1882
|
({ x, y } = clickPoint(box, st, persona));
|
|
1653
1883
|
}
|
|
1884
|
+
// Whole pixels: the hit test below answers for the pixel DOM.getNodeForLocation rounds to, so the
|
|
1885
|
+
// press goes to that pixel too. A press at 971.6 next to a badge ending at 972 was proven at 972 and
|
|
1886
|
+
// landed on the badge, reported as a success (verifier B, SC 210).
|
|
1887
|
+
x = roundHalfEven(x);
|
|
1888
|
+
y = roundHalfEven(y);
|
|
1654
1889
|
if (!o.force && orig) {
|
|
1655
1890
|
// Stability, then covered-by: 50 ms on, the element must still fill `box` (an animation in
|
|
1656
1891
|
// flight does not) and own the click point -- no trusted press at a point some overlay owns.
|
|
1657
1892
|
// Either failing goes native: Playwright waits for it.
|
|
1658
1893
|
await sleep(50);
|
|
1659
|
-
if (!(await reachesPoint(
|
|
1894
|
+
if (!(await reachesPoint(box, x, y, selector)))
|
|
1660
1895
|
throw new NativeSignal();
|
|
1661
1896
|
}
|
|
1662
1897
|
if (o.modifiers.length && !(nativeKbDown && nativeKbUp))
|
|
1663
1898
|
throw new NativeSignal();
|
|
1899
|
+
if (!noClick && !o.force && orig && engineClicksMode() === "all" && enginePathOk(o) && await engineGlide(x, y, false)) {
|
|
1900
|
+
// Opt-in (CLEARCOTE_HUMANIZE_ENGINE_CLICKS=all): the engine walks the approach and clicks the
|
|
1901
|
+
// point the hit test above proved; plain left single clicks only, and only while the engine
|
|
1902
|
+
// knows where the pointer is (engineInSync). Otherwise: the planner.
|
|
1903
|
+
return;
|
|
1904
|
+
}
|
|
1664
1905
|
if (noClick && o.modifiers.length) {
|
|
1665
1906
|
// hover with modifiers: Playwright holds them across the move
|
|
1666
1907
|
const held = [];
|
|
@@ -1695,8 +1936,10 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
1695
1936
|
// outside the try, so a native call that raises is not run a second time by the handler
|
|
1696
1937
|
return native();
|
|
1697
1938
|
};
|
|
1698
|
-
page.click = (selector, options = {}) => locked(() => targetedClick(nativePageClick, selector, options, false, null));
|
|
1699
|
-
page.hover = (selector, options = {}) => locked(() => targetedClick(nativePageHover, selector, options, true, null));
|
|
1939
|
+
page.click = apiErrors("page.click", (selector, options = {}) => locked(() => targetedClick(nativePageClick, selector, options, false, null)));
|
|
1940
|
+
page.hover = apiErrors("page.hover", (selector, options = {}) => locked(() => targetedClick(nativePageHover, selector, options, true, null)));
|
|
1941
|
+
// Locator.dragTo({ trial: true }) answers its checks here too (see the Locator patch).
|
|
1942
|
+
page._clearcoteTrial = (selector, options, noClick) => locked(() => trial(selector, options, noClick));
|
|
1700
1943
|
// Marker the Locator-class patch keys off: humanize is active for THIS page.
|
|
1701
1944
|
page._clearcoteHumanized = true;
|
|
1702
1945
|
patchLocatorClass(page.locator("html"));
|
|
@@ -1817,66 +2060,102 @@ function patchLocatorClass(sample) {
|
|
|
1817
2060
|
return [true, await fn()];
|
|
1818
2061
|
return [false, undefined];
|
|
1819
2062
|
};
|
|
1820
|
-
|
|
2063
|
+
// Every routed method words its errors as Playwright words the Locator method (asApiError): through
|
|
2064
|
+
// page.*, an error read "page.click: ..." or "locator.waitFor: ...".
|
|
2065
|
+
const apiMethod = (api, fn) => async function (...a) {
|
|
2066
|
+
try {
|
|
2067
|
+
return await fn.apply(this, a);
|
|
2068
|
+
}
|
|
2069
|
+
catch (e) {
|
|
2070
|
+
throw asApiError(e, api);
|
|
2071
|
+
}
|
|
2072
|
+
};
|
|
2073
|
+
// Playwright's tap rejects at once in a context without hasTouch ("The page does not support tap");
|
|
2074
|
+
// routed as a click it clicked instead (SC 210 error parity). A context it cannot read is routed.
|
|
2075
|
+
const noTouch = (self) => {
|
|
2076
|
+
try {
|
|
2077
|
+
return self.page().context()._options.hasTouch !== true;
|
|
2078
|
+
}
|
|
2079
|
+
catch {
|
|
2080
|
+
return false;
|
|
2081
|
+
}
|
|
2082
|
+
};
|
|
2083
|
+
proto.fill = apiMethod("locator.fill", async function (value, kw = {}) {
|
|
1821
2084
|
const [done, r] = await route(this, () => this.page().fill(sel(this), value, pickOptions(kw, FILL_OPTS)));
|
|
1822
2085
|
return done ? r : oFill.call(this, value, kw);
|
|
1823
|
-
};
|
|
1824
|
-
proto.click = async function (kw = {}) {
|
|
2086
|
+
});
|
|
2087
|
+
proto.click = apiMethod("locator.click", async function (kw = {}) {
|
|
1825
2088
|
const [done, r] = await route(this, () => this.page().click(sel(this), pickOptions(kw, CLICK_OPTS)));
|
|
1826
2089
|
return done ? r : oClick.call(this, kw);
|
|
1827
|
-
};
|
|
1828
|
-
proto.type = async function (text, kw = {}) {
|
|
2090
|
+
});
|
|
2091
|
+
proto.type = apiMethod("locator.type", async function (text, kw = {}) {
|
|
1829
2092
|
const [done, r] = await route(this, () => this.page().type(sel(this), text, pickOptions(kw, TYPE_OPTS)));
|
|
1830
2093
|
return done ? r : oType.call(this, text, kw);
|
|
1831
|
-
};
|
|
1832
|
-
proto.dblclick = async function (kw = {}) {
|
|
2094
|
+
});
|
|
2095
|
+
proto.dblclick = apiMethod("locator.dblclick", async function (kw = {}) {
|
|
1833
2096
|
const [done, r] = await route(this, () => this.page().dblclick(sel(this), pickOptions(kw, DBL_OPTS)));
|
|
1834
2097
|
return done ? r : oDbl.call(this, kw);
|
|
1835
|
-
};
|
|
1836
|
-
proto.hover = async function (kw = {}) {
|
|
2098
|
+
});
|
|
2099
|
+
proto.hover = apiMethod("locator.hover", async function (kw = {}) {
|
|
1837
2100
|
const [done, r] = await route(this, () => this.page().hover(sel(this), pickOptions(kw, HOVER_OPTS)));
|
|
1838
2101
|
return done ? r : oHover.call(this, kw);
|
|
1839
|
-
};
|
|
1840
|
-
proto.press = async function (key, kw = {}) {
|
|
2102
|
+
});
|
|
2103
|
+
proto.press = apiMethod("locator.press", async function (key, kw = {}) {
|
|
1841
2104
|
const [done, r] = await route(this, () => this.page().press(sel(this), key, pickOptions(kw, PRESS_OPTS)));
|
|
1842
2105
|
return done ? r : oPress.call(this, key, kw);
|
|
1843
|
-
};
|
|
1844
|
-
proto.pressSequentially = async function (text, kw = {}) {
|
|
2106
|
+
});
|
|
2107
|
+
proto.pressSequentially = apiMethod("locator.pressSequentially", async function (text, kw = {}) {
|
|
1845
2108
|
const [done, r] = await route(this, () => this.page().type(sel(this), text, pickOptions(kw, TYPE_OPTS)));
|
|
1846
2109
|
return done ? r : oPressSeq.call(this, text, kw);
|
|
1847
|
-
};
|
|
1848
|
-
proto.clear = async function (kw = {}) {
|
|
2110
|
+
});
|
|
2111
|
+
proto.clear = apiMethod("locator.clear", async function (kw = {}) {
|
|
1849
2112
|
const [done, r] = await route(this, () => this.page().fill(sel(this), "", pickOptions(kw, FILL_OPTS)));
|
|
1850
2113
|
return done ? r : oClear.call(this, kw);
|
|
1851
|
-
};
|
|
1852
|
-
proto.tap = async function (kw = {}) {
|
|
2114
|
+
});
|
|
2115
|
+
proto.tap = apiMethod("locator.tap", async function (kw = {}) {
|
|
2116
|
+
if (noTouch(this))
|
|
2117
|
+
return oTap.call(this, kw);
|
|
1853
2118
|
const [done, r] = await route(this, () => this.page().click(sel(this), pickOptions(kw, HOVER_OPTS)));
|
|
1854
2119
|
return done ? r : oTap.call(this, kw);
|
|
1855
|
-
};
|
|
1856
|
-
proto.check = async function (kw = {}) {
|
|
2120
|
+
});
|
|
2121
|
+
proto.check = apiMethod("locator.check", async function (kw = {}) {
|
|
1857
2122
|
const [done, r] = await route(this, () => this.page().check(sel(this), pickOptions(kw, CHECK_OPTS)));
|
|
1858
2123
|
return done ? r : oCheck.call(this, kw);
|
|
1859
|
-
};
|
|
1860
|
-
proto.uncheck = async function (kw = {}) {
|
|
2124
|
+
});
|
|
2125
|
+
proto.uncheck = apiMethod("locator.uncheck", async function (kw = {}) {
|
|
1861
2126
|
const [done, r] = await route(this, () => this.page().uncheck(sel(this), pickOptions(kw, CHECK_OPTS)));
|
|
1862
2127
|
return done ? r : oUncheck.call(this, kw);
|
|
1863
|
-
};
|
|
2128
|
+
});
|
|
1864
2129
|
if (typeof oSetChecked === "function") {
|
|
1865
|
-
proto.setChecked = async function (checked, kw = {}) {
|
|
2130
|
+
proto.setChecked = apiMethod("locator.setChecked", async function (checked, kw = {}) {
|
|
1866
2131
|
const [done, r] = await route(this, () => this.page().setChecked(sel(this), checked, pickOptions(kw, CHECK_OPTS)));
|
|
1867
2132
|
return done ? r : oSetChecked.call(this, checked, kw);
|
|
1868
|
-
};
|
|
2133
|
+
});
|
|
1869
2134
|
}
|
|
1870
2135
|
// Delegate to the humanized page.selectOption, like every other method here: one implementation
|
|
1871
2136
|
// behind the page method is what stops the two entry points drifting apart.
|
|
1872
2137
|
if (typeof oSelectOption === "function") {
|
|
1873
|
-
proto.selectOption = async function (values, kw = {}) {
|
|
2138
|
+
proto.selectOption = apiMethod("locator.selectOption", async function (values, kw = {}) {
|
|
1874
2139
|
const [done, r] = await route(this, () => this.page().selectOption(sel(this), values, pickOptions(kw, SELECT_OPTS)));
|
|
1875
2140
|
return done ? r : oSelectOption.call(this, values, kw);
|
|
1876
|
-
};
|
|
2141
|
+
});
|
|
1877
2142
|
}
|
|
1878
|
-
proto.dragTo = async function (target, kw = {}) {
|
|
1879
|
-
if (on(this)) {
|
|
2143
|
+
proto.dragTo = apiMethod("locator.dragTo", async function (target, kw = {}) {
|
|
2144
|
+
if (kw?.trial && on(this) && on(target) && await strict(this) && await strict(target)) {
|
|
2145
|
+
// Playwright's drag trial moves the pointer onto the source and presses there (SC 209); the SDK
|
|
2146
|
+
// runs the checks of both ends instead: visible, stable, receives the pointer.
|
|
2147
|
+
const t = this.page()._clearcoteTrial;
|
|
2148
|
+
if (typeof t === "function") {
|
|
2149
|
+
for (const [loc, pos] of [[this, kw.sourcePosition], [target, kw.targetPosition]]) {
|
|
2150
|
+
const o = pickOptions(kw, ["force", "timeout"]);
|
|
2151
|
+
if (pos)
|
|
2152
|
+
o.position = pos;
|
|
2153
|
+
await t(sel(loc), o, true);
|
|
2154
|
+
}
|
|
2155
|
+
return;
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
if (on(this) && !kw?.trial) {
|
|
1880
2159
|
try {
|
|
1881
2160
|
const pg = this.page();
|
|
1882
2161
|
const sb = await this.boundingBox();
|
|
@@ -1889,9 +2168,8 @@ function patchLocatorClass(sample) {
|
|
|
1889
2168
|
const persona = pg._clearcotePersona;
|
|
1890
2169
|
const { grabMs, releaseMs } = persona ? dragDwell(persona) : { grabMs: rand(130, 360), releaseMs: rand(90, 230) };
|
|
1891
2170
|
await pg.mouse.move(sx, sy);
|
|
1892
|
-
await sleep(rand(100, 200));
|
|
1893
2171
|
await pg.mouse.down();
|
|
1894
|
-
await sleep(grabMs); //
|
|
2172
|
+
await sleep(grabMs); // pre-press pause inside down(); then the grab hesitation
|
|
1895
2173
|
const heldGlide = pg._clearcoteHeldGlide; // humanized held-button drag + seating jiggle (settle)
|
|
1896
2174
|
if (heldGlide)
|
|
1897
2175
|
await heldGlide(tx, ty);
|
|
@@ -1905,7 +2183,7 @@ function patchLocatorClass(sample) {
|
|
|
1905
2183
|
catch { /* fall back */ }
|
|
1906
2184
|
}
|
|
1907
2185
|
return oDragTo.call(this, target, kw);
|
|
1908
|
-
};
|
|
2186
|
+
});
|
|
1909
2187
|
}
|
|
1910
2188
|
/** Install humanize/showCursor on a browser: wraps newPage/newContext so every page is covered. */
|
|
1911
2189
|
export function installHumanize(browser, opts) {
|