clearcote 0.47.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 +33 -14
- package/dist/cloud.d.ts.map +1 -1
- package/dist/cloud.js +5 -0
- package/dist/cloud.js.map +1 -1
- package/dist/docker.d.ts.map +1 -1
- package/dist/docker.js +5 -1
- package/dist/docker.js.map +1 -1
- package/dist/download.d.ts +2 -0
- package/dist/download.d.ts.map +1 -1
- package/dist/download.js +2 -1
- package/dist/download.js.map +1 -1
- package/dist/forms.d.ts +233 -0
- package/dist/forms.d.ts.map +1 -0
- package/dist/forms.js +736 -0
- package/dist/forms.js.map +1 -0
- package/dist/humanize.d.ts +66 -3
- package/dist/humanize.d.ts.map +1 -1
- package/dist/humanize.js +1523 -361
- package/dist/humanize.js.map +1 -1
- package/dist/index.d.ts +19 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -12
- package/dist/index.js.map +1 -1
- package/dist/isolated.d.ts +91 -4
- package/dist/isolated.d.ts.map +1 -1
- package/dist/isolated.js +311 -27
- 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/dist/widevine.d.ts +90 -5
- package/dist/widevine.d.ts.map +1 -1
- package/dist/widevine.js +391 -40
- package/dist/widevine.js.map +1 -1
- package/package.json +1 -1
package/dist/humanize.js
CHANGED
|
@@ -4,13 +4,15 @@
|
|
|
4
4
|
// dragging, scrolling and typing — dispatched as native trusted input (isTrusted===true,
|
|
5
5
|
// navigator.webdriver stays false). It works whether you drive the page directly or via
|
|
6
6
|
// locators:
|
|
7
|
-
// page level — page.click / hover / dblclick / type / fill / press / selectOption
|
|
8
|
-
//
|
|
9
|
-
// page.
|
|
7
|
+
// page level — page.click / hover / dblclick / type / fill / press / selectOption /
|
|
8
|
+
// check / uncheck / setChecked,
|
|
9
|
+
// page.mouse.move / click / dblclick / wheel and held-button drags
|
|
10
|
+
// (down -> move -> up), page.keyboard.type / press
|
|
10
11
|
// locator level — locator.click / type / fill / hover / dblclick / press /
|
|
11
|
-
// pressSequentially / clear / check / uncheck /
|
|
12
|
+
// pressSequentially / clear / check / uncheck / setChecked / tap /
|
|
13
|
+
// selectOption / dragTo
|
|
12
14
|
// (routed through the humanized page methods; main-frame locators only,
|
|
13
|
-
// native fallback)
|
|
15
|
+
// STRICT like Playwright, native fallback)
|
|
14
16
|
// `showCursor: true` — injects a red cursor dot that follows the real mousemove events.
|
|
15
17
|
//
|
|
16
18
|
// Mouse moves build an eased, slightly bowed cubic-bezier path SDK-side from the tracked cursor
|
|
@@ -19,8 +21,16 @@
|
|
|
19
21
|
// (slider captchas work) with no separate CDP channel to desync. Typing goes key-by-key through
|
|
20
22
|
// the native keyboard (trusted; shift handled by the engine) with human inter-key timing. No
|
|
21
23
|
// special binary command required.
|
|
24
|
+
//
|
|
25
|
+
// Form input follows sdk/python/clearcote/_humanize.py (the reference); WHAT to press comes from
|
|
26
|
+
// ./forms.ts (shared test vectors with Python and .NET), HOW comes from this file. Every DOM read
|
|
27
|
+
// goes through the isolated world (./isolated.ts) or Playwright's utility-world queries, never
|
|
28
|
+
// page.evaluate / locator.evaluate.
|
|
29
|
+
import { errors as pwErrors } from "playwright-core";
|
|
22
30
|
import { makePersona, planMove, dragDwell, clickHold, keyDwell, clickPoint, planAmbient, gaussFrom, rolloverRate, wheelNotches, platformFromNavigator, WHEEL_NOTCH_PX, } from "./motion.js";
|
|
23
|
-
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";
|
|
24
34
|
/** Overlay: a red dot that follows real mousemove events. Idempotent IIFE so it works both as
|
|
25
35
|
* an addInitScript source string AND when passed to page.evaluate. */
|
|
26
36
|
export const CURSOR_OVERLAY = `(() => {
|
|
@@ -47,22 +57,24 @@ const ROLL_KEYS = new Set("abcdefghijklmnopqrstuvwxyz0123456789 ,.;'/-=");
|
|
|
47
57
|
/** The persona platform when the page cannot say: the SDK's default persona follows the host. */
|
|
48
58
|
const hostWheelPlatform = () => process.platform === "win32" ? "windows" : process.platform === "darwin" ? "macos" : "linux";
|
|
49
59
|
// The engine's pointer-path command. r34 renamed Browser.humanizedClick to Browser.dispatchPointerPath:
|
|
50
|
-
//
|
|
51
|
-
//
|
|
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
|
|
52
62
|
// name, so the new one goes first and the old one is the fallback for a method-not-found only.
|
|
53
63
|
/** @internal exported for tests */
|
|
54
64
|
export const POINTER_PATH = "Browser.dispatchPointerPath";
|
|
55
65
|
/** @internal exported for tests */
|
|
56
66
|
export const POINTER_PATH_LEGACY = "Browser.humanizedClick";
|
|
57
|
-
// 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.
|
|
58
69
|
const engineMethodNames = new WeakMap();
|
|
70
|
+
const NO_ENGINE_PATH = "";
|
|
59
71
|
// A CDP method-not-found: the engine predates the name. A raw CDP client sees code -32601;
|
|
60
72
|
// Playwright's CDPSession keeps only the message ("Protocol error (X): 'X' wasn't found").
|
|
61
73
|
const isMethodNotFound = (e) => e?.code === -32601 || /-32601|wasn't found|method not found/i.test(String(e?.message ?? e));
|
|
62
74
|
/** @internal exported for tests. Send `method`, or `legacy` when the engine predates the rename, and
|
|
63
|
-
* remember per `cacheKey` (a Browser, or the context when there is none) which one answered
|
|
64
|
-
*
|
|
65
|
-
* 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. */
|
|
66
78
|
export async function sendEngineCommand(session, cacheKey, method, legacy, params) {
|
|
67
79
|
let names = engineMethodNames.get(cacheKey);
|
|
68
80
|
if (!names) {
|
|
@@ -70,6 +82,8 @@ export async function sendEngineCommand(session, cacheKey, method, legacy, param
|
|
|
70
82
|
engineMethodNames.set(cacheKey, names);
|
|
71
83
|
}
|
|
72
84
|
const known = names.get(method);
|
|
85
|
+
if (known === NO_ENGINE_PATH)
|
|
86
|
+
throw Object.assign(new Error(`'${method}' wasn't found`), { code: -32601 });
|
|
73
87
|
if (known)
|
|
74
88
|
return session.send(known, params);
|
|
75
89
|
try {
|
|
@@ -81,10 +95,42 @@ export async function sendEngineCommand(session, cacheKey, method, legacy, param
|
|
|
81
95
|
if (!isMethodNotFound(e))
|
|
82
96
|
throw e;
|
|
83
97
|
}
|
|
84
|
-
|
|
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
|
+
}
|
|
85
107
|
names.set(method, legacy);
|
|
86
108
|
return r;
|
|
87
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
|
+
}
|
|
88
134
|
// The characters a US-layout keyboard (Playwright's, and the engine's keyboard.getLayoutMap())
|
|
89
135
|
// types with Shift held: capitals and the shifted symbols.
|
|
90
136
|
const SHIFTED_SYMBOLS = new Set('~!@#$%^&*()_+{}|:"<>?');
|
|
@@ -95,24 +141,109 @@ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
|
95
141
|
// for placing a point in a plausible spread instead of on an exact landmark.
|
|
96
142
|
const gauss = () => gaussFrom(Math.random);
|
|
97
143
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
return
|
|
144
|
+
// The letter-adjacency map now lives in ./forms.ts (shared with Python and .NET); kept under the old
|
|
145
|
+
// name for anything that imported it from here.
|
|
146
|
+
/** @internal */
|
|
147
|
+
export const NEARBY = NEARBY_LETTERS;
|
|
148
|
+
/** @internal exported for tests. Per-character fat-finger rate. CLEARCOTE_HUMANIZE_TYPO_RATE
|
|
149
|
+
* overrides it (0 disables typos; the test suites raise it to exercise the correction paths). Read at
|
|
150
|
+
* call time, like Python's typo_rate(). */
|
|
151
|
+
export function typoRate() {
|
|
152
|
+
const v = process.env.CLEARCOTE_HUMANIZE_TYPO_RATE;
|
|
153
|
+
if (v === undefined || v === "")
|
|
154
|
+
return DEFAULT_TYPO_RATE;
|
|
155
|
+
const f = pyFloat(v);
|
|
156
|
+
if (f === null || Number.isNaN(f))
|
|
157
|
+
return DEFAULT_TYPO_RATE;
|
|
158
|
+
return Math.min(1.0, Math.max(0.0, f));
|
|
159
|
+
}
|
|
160
|
+
/** @internal exported for tests. A Playwright timeout: the element never became actionable. Falling
|
|
161
|
+
* back to the native call after one would wait the FULL timeout again (a missing element took 2-3x
|
|
162
|
+
* `timeout` to fail), so timeouts propagate instead. */
|
|
163
|
+
export function isTimeout(err) {
|
|
164
|
+
return err?.name === "TimeoutError";
|
|
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
|
+
}
|
|
212
|
+
/** Internal: the humane route cannot apply here; run the native Playwright call instead. */
|
|
213
|
+
class NativeSignal extends Error {
|
|
214
|
+
constructor() { super("clearcote humanize: native route"); this.name = "NativeSignal"; }
|
|
215
|
+
}
|
|
216
|
+
/** @internal exported for tests. A per-page lock: each wrapped public entry point (page.click / fill
|
|
217
|
+
* / type / press / selectOption / check / uncheck / setChecked / dblclick / hover, mouse.click /
|
|
218
|
+
* dblclick, keyboard.type / press) runs one at a time. Without it a `Promise.all` of two fills on one
|
|
219
|
+
* page interleaved their clicks and keys and left each field with part of the other's text: a page has
|
|
220
|
+
* one pointer and one keyboard focus. Internal steps call internal helpers, never a locked entry point,
|
|
221
|
+
* so nothing waits on itself. A failed call releases the lock like a successful one. */
|
|
222
|
+
export function createInputLock() {
|
|
223
|
+
let tail = Promise.resolve();
|
|
224
|
+
return (fn) => {
|
|
225
|
+
const run = tail.then(() => fn());
|
|
226
|
+
tail = run.then(() => undefined, () => undefined);
|
|
227
|
+
return run;
|
|
228
|
+
};
|
|
113
229
|
}
|
|
114
|
-
const SELECT_ALL = "Control+a"; // Clearcote ships a Windows binary; Control is correct.
|
|
115
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
|
+
}
|
|
116
247
|
export async function attachHumanize(browser, page, opts) {
|
|
117
248
|
// IDEMPOTENCY GUARD — do not remove.
|
|
118
249
|
//
|
|
@@ -144,6 +275,8 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
144
275
|
// seeds. Stored on the page so the module-level Locator.dragTo patch can reach it too.
|
|
145
276
|
const persona = makePersona(opts.seed);
|
|
146
277
|
page._clearcotePersona = persona;
|
|
278
|
+
// One input action at a time on this page (see createInputLock).
|
|
279
|
+
const locked = createInputLock();
|
|
147
280
|
// Tracked cursor position so each move continues from where the last one ended.
|
|
148
281
|
// `held` tracks whether a mouse button is currently down — see mouse.move below.
|
|
149
282
|
// `placed` records whether the engine's cursor has actually been moved yet. A page starts with the
|
|
@@ -160,22 +293,37 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
160
293
|
// Walk a planned trajectory (motion.ts) with native (trusted) mouse.move + off-protocol sleeps.
|
|
161
294
|
// Native input carries the button state, so a button pressed via mouse.down() stays held across
|
|
162
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.
|
|
163
302
|
const dispatch = async (steps) => {
|
|
164
|
-
|
|
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;
|
|
165
313
|
try {
|
|
166
|
-
await
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
break;
|
|
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]) };
|
|
170
317
|
}
|
|
171
|
-
|
|
172
|
-
|
|
318
|
+
catch { /* detached / navigating / no CDP: no clamp */ }
|
|
319
|
+
vb = { t: now, vp };
|
|
173
320
|
}
|
|
321
|
+
return vb.vp;
|
|
174
322
|
};
|
|
175
323
|
// Move to (x, y) as a minimum-jerk sum-of-submovements path (primary ballistic + corrective homing)
|
|
176
324
|
// with colored sub-pixel noise and Fitts-scaled duration. `settle` adds a seating jiggle (drag).
|
|
177
325
|
const glide = async (x, y, o = {}) => {
|
|
178
|
-
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 }));
|
|
179
327
|
st.x = x;
|
|
180
328
|
st.y = y;
|
|
181
329
|
};
|
|
@@ -184,11 +332,23 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
184
332
|
// recorded human paths for PRO, engine bezier for free, tiered by the engine license gate).
|
|
185
333
|
// Held-button DRAGS stay native; falls back to the SDK bezier if the method is unavailable
|
|
186
334
|
// (older engine / no browser session).
|
|
187
|
-
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);
|
|
188
342
|
const engineGlide = async (x, y, noClick) => {
|
|
189
|
-
if (!eng.ok)
|
|
343
|
+
if (!eng.ok || !engineInSync())
|
|
190
344
|
return false;
|
|
191
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
|
+
}
|
|
192
352
|
if (eng.session === null) {
|
|
193
353
|
const ctx = page.context();
|
|
194
354
|
const tsession = await ctx.newCDPSession(page);
|
|
@@ -201,13 +361,17 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
201
361
|
const dx = x - st.x, dy = y - st.y;
|
|
202
362
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
203
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();
|
|
204
365
|
await sendEngineCommand(eng.session, eng.key, POINTER_PATH, POINTER_PATH_LEGACY, { targetId: eng.tid, x, y, duration: dur, noClick });
|
|
205
366
|
// The engine walks the trajectory in the BROWSER process and answers this command before it
|
|
206
367
|
// has finished. Without waiting, a following mouse.down() fires at the cursor's STALE
|
|
207
368
|
// position — measured: a move to (110,58) then a press landed at (981,629), on <body>
|
|
208
369
|
// instead of the target, so every move-then-press sequence (drag, press-and-hold,
|
|
209
370
|
// drag-and-drop) acted on the wrong element.
|
|
210
|
-
|
|
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)));
|
|
211
375
|
try {
|
|
212
376
|
await nativeMove(x, y);
|
|
213
377
|
}
|
|
@@ -215,6 +379,7 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
215
379
|
st.x = x;
|
|
216
380
|
st.y = y;
|
|
217
381
|
st.placed = true;
|
|
382
|
+
eng.at = { x, y };
|
|
218
383
|
return true;
|
|
219
384
|
}
|
|
220
385
|
catch {
|
|
@@ -250,7 +415,9 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
250
415
|
// an action needs one but names no coordinates.
|
|
251
416
|
const ambient = async (ms) => {
|
|
252
417
|
try {
|
|
253
|
-
|
|
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);
|
|
254
421
|
await dispatch(steps);
|
|
255
422
|
if (steps.length) {
|
|
256
423
|
st.x = steps[steps.length - 1].x;
|
|
@@ -295,7 +462,9 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
295
462
|
// mouse.click keeps engine routing: it performs move+click entirely engine-side, where nothing
|
|
296
463
|
// races the trajectory. glide() is still fully humanized (minimum-jerk, colored noise, Fitts).
|
|
297
464
|
mouse.move = async (x, y) => {
|
|
298
|
-
|
|
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 });
|
|
299
468
|
};
|
|
300
469
|
// down/up are wrapped to TRACK the held state mouse.move needs, and — critically — to make the
|
|
301
470
|
// press position DETERMINISTIC. The engine answers its pointer path before the trajectory has
|
|
@@ -303,6 +472,29 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
303
472
|
// enough: measured on Node, a move to (110,58) was still mid-flight at (416,504) when the press
|
|
304
473
|
// fired, landing on <body>. Re-issuing a native move to the tracked target immediately before
|
|
305
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
|
+
};
|
|
306
498
|
mouse.down = async (...a) => {
|
|
307
499
|
// DELIBERATELY NO PLACEMENT WHEN THE POINTER HAS NEVER MOVED.
|
|
308
500
|
//
|
|
@@ -321,6 +513,9 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
321
513
|
await nativeMove(st.x, st.y);
|
|
322
514
|
}
|
|
323
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();
|
|
324
519
|
}
|
|
325
520
|
st.held = true;
|
|
326
521
|
try {
|
|
@@ -337,17 +532,33 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
337
532
|
finally {
|
|
338
533
|
st.held = false;
|
|
339
534
|
} };
|
|
340
|
-
|
|
341
|
-
|
|
535
|
+
// The engine path performs ONE plain left click. Anything else -- right/middle button, a double
|
|
536
|
+
// click, a caller-chosen hold -- glides SDK-side and presses as asked (pressAt); the old wrapper
|
|
537
|
+
// dropped those options and always left-clicked once.
|
|
538
|
+
const hclick = async (x, y, options = {}) => {
|
|
539
|
+
const o = normalizeClickOptions(options);
|
|
540
|
+
if (enginePathOk(o) && engineClicksMode() !== "off" && await engineGlide(x, y, false))
|
|
342
541
|
return;
|
|
343
542
|
await glide(x, y);
|
|
344
|
-
await sleep(rand(40, 130)); // brief dwell before pressing, like a human
|
|
345
|
-
// delay = the mousedown→mouseup HOLD (human ~60–150 ms); without it Playwright releases in ~2 ms.
|
|
346
543
|
try {
|
|
347
|
-
|
|
544
|
+
if (o.modifiers.length && !(nativeKbDown && nativeKbUp)) {
|
|
545
|
+
await prePress(); // the hand confirms the target before it presses
|
|
546
|
+
// delay = the mousedown→mouseup HOLD (human ~60–150 ms); without it Playwright releases in ~2 ms.
|
|
547
|
+
await nativeClick(x, y, { delay: o.delay ?? clickHold(persona), button: o.button, clickCount: o.clickCount });
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
await pressAt(o);
|
|
348
551
|
}
|
|
349
552
|
catch { /* best-effort */ }
|
|
350
553
|
};
|
|
554
|
+
mouse.click = (x, y, options = {}) => locked(() => hclick(x, y, options));
|
|
555
|
+
// Stock dblclick teleported and pressed for ~0 ms: route it through the humanized click.
|
|
556
|
+
if (typeof mouse.dblclick === "function") {
|
|
557
|
+
mouse.dblclick = (x, y, options = {}) => {
|
|
558
|
+
const { click_count: _cc, ...rest } = options ?? {};
|
|
559
|
+
return locked(() => hclick(x, y, { ...rest, clickCount: 2 }));
|
|
560
|
+
};
|
|
561
|
+
}
|
|
351
562
|
// Opt-in ambient / pre-challenge cursor activity: idle drift + a few non-goal moves so a behavioral
|
|
352
563
|
// collector sees non-zero pointer entropy BEFORE the first goal action (the biggest reason a
|
|
353
564
|
// challenge/slider is shown to headless automation). `await page.ambientMotion(ms)`.
|
|
@@ -450,6 +661,8 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
450
661
|
const keyboard = page.keyboard;
|
|
451
662
|
const nativeKbType = keyboard.type.bind(keyboard);
|
|
452
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;
|
|
453
666
|
// A page-like keyboard without down/up still gets every other humanization; it just types
|
|
454
667
|
// capitals without the Shift key, as before.
|
|
455
668
|
const nativeKbDown = typeof keyboard.down === "function" ? keyboard.down.bind(keyboard) : undefined;
|
|
@@ -468,6 +681,137 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
468
681
|
catch { /* best-effort */ }
|
|
469
682
|
}
|
|
470
683
|
};
|
|
684
|
+
// Press `k` (n times) with the persona's hold on every press and a human gap between repeats.
|
|
685
|
+
const key = async (k, n = 1, gap = [60, 150]) => {
|
|
686
|
+
for (let j = 0; j < n; j++) {
|
|
687
|
+
await nativeKbPress(k, { delay: keyDwell(persona) });
|
|
688
|
+
if (j < n - 1)
|
|
689
|
+
await sleep(rand(gap[0], gap[1]));
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
// Press at the point the last glide ended, the way the options ask: the button, the click count (a
|
|
693
|
+
// double click is two presses with a human gap, not one event), the caller's hold (`delay`) or the
|
|
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
|
|
696
|
+
// down/up, so nothing moves.
|
|
697
|
+
const pressAt = async (o) => {
|
|
698
|
+
const held = [];
|
|
699
|
+
try {
|
|
700
|
+
for (const m of o.modifiers) {
|
|
701
|
+
await nativeKbDown(m);
|
|
702
|
+
held.push(m);
|
|
703
|
+
await sleep(rand(40, 110));
|
|
704
|
+
}
|
|
705
|
+
await prePress(); // the hand confirms the target before it presses
|
|
706
|
+
const n = o.clickCount;
|
|
707
|
+
for (let i = 1; i <= n; i++) {
|
|
708
|
+
const hold = o.delay ?? clickHold(persona);
|
|
709
|
+
const t0 = Date.now();
|
|
710
|
+
await nativeDown({ button: o.button, clickCount: i });
|
|
711
|
+
try {
|
|
712
|
+
await sleep(holdRest(hold, t0));
|
|
713
|
+
}
|
|
714
|
+
finally {
|
|
715
|
+
await nativeUp({ button: o.button, clickCount: i }); // never leave the button down
|
|
716
|
+
}
|
|
717
|
+
if (i < n)
|
|
718
|
+
await sleep(rand(70, 170)); // the gap inside a double click
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
finally {
|
|
722
|
+
for (const m of held.reverse()) {
|
|
723
|
+
await sleep(rand(20, 80));
|
|
724
|
+
try {
|
|
725
|
+
await nativeKbUp(m);
|
|
726
|
+
}
|
|
727
|
+
catch { /* best-effort */ }
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
};
|
|
731
|
+
// The focused field's current value, read in the isolated world (null if unknown).
|
|
732
|
+
const activeValue = async () => {
|
|
733
|
+
try {
|
|
734
|
+
const v = await worldFor(page).evaluate(ISO_ACTIVE_VALUE);
|
|
735
|
+
return typeof v === "string" ? v : null;
|
|
736
|
+
}
|
|
737
|
+
catch {
|
|
738
|
+
return null;
|
|
739
|
+
}
|
|
740
|
+
};
|
|
741
|
+
// Node's inter-key flight (the cadence humanTypeText uses between characters).
|
|
742
|
+
const flightMs = () => rand(45, 150);
|
|
743
|
+
const backspaces = async (n) => {
|
|
744
|
+
for (let k = 0; k < n; k++) {
|
|
745
|
+
await nativeKbPress("Backspace", { delay: keyDwell(persona) });
|
|
746
|
+
if (k < n - 1)
|
|
747
|
+
await sleep(rand(70, 150));
|
|
748
|
+
}
|
|
749
|
+
};
|
|
750
|
+
// How many characters arrived since `before` (null = cannot tell).
|
|
751
|
+
const landed = (before, now) => before === null || now === null ? null : Array.from(now).length - Array.from(before).length;
|
|
752
|
+
// Make the planned slip at chars[i] and correct it. Returns true when it already typed chars[i]
|
|
753
|
+
// itself (the doubled-key case), so the caller must not type it again.
|
|
754
|
+
//
|
|
755
|
+
// Only what actually LANDED is deleted: an input mask or a maxlength can swallow the wrong key, and
|
|
756
|
+
// a Backspace pressed for a key that never arrived deletes a real character (the
|
|
757
|
+
// letter-typo-in-a-numeric-mask failure). Measured before and after the slip, in the isolated world,
|
|
758
|
+
// one read each -- typos are ~2 % of keys, so this costs nearly nothing.
|
|
759
|
+
const doTypo = async (chars, i, tp) => {
|
|
760
|
+
const before = await activeValue();
|
|
761
|
+
if (tp.kind === "sub") {
|
|
762
|
+
await emitKey(tp.wrong);
|
|
763
|
+
let got = landed(before, await activeValue());
|
|
764
|
+
if (got !== null && got <= 0)
|
|
765
|
+
return false; // swallowed: nothing to correct
|
|
766
|
+
let typed = 1;
|
|
767
|
+
for (const c of chars.slice(i + 1, i + 1 + tp.late)) { // late notice: a key or two more
|
|
768
|
+
await sleep(flightMs());
|
|
769
|
+
await emitKey(c);
|
|
770
|
+
typed++;
|
|
771
|
+
}
|
|
772
|
+
await sleep(rand(140, 340)); // noticing
|
|
773
|
+
got = landed(before, await activeValue());
|
|
774
|
+
await backspaces(got === null ? typed : Math.max(0, Math.min(typed, got)));
|
|
775
|
+
await sleep(rand(80, 200));
|
|
776
|
+
return false;
|
|
777
|
+
}
|
|
778
|
+
if (tp.kind === "double") {
|
|
779
|
+
await emitKey(chars[i]);
|
|
780
|
+
await sleep(rand(45, 95)); // the bounce comes fast
|
|
781
|
+
await emitKey(chars[i]);
|
|
782
|
+
const got = landed(before, await activeValue());
|
|
783
|
+
await sleep(rand(140, 340));
|
|
784
|
+
const extra = got === null ? 1 : Math.max(0, got - 1);
|
|
785
|
+
if (extra) {
|
|
786
|
+
await backspaces(extra);
|
|
787
|
+
await sleep(rand(80, 200));
|
|
788
|
+
}
|
|
789
|
+
return true;
|
|
790
|
+
}
|
|
791
|
+
if (tp.kind === "swap") {
|
|
792
|
+
await emitKey(chars[i + 1]);
|
|
793
|
+
await sleep(flightMs());
|
|
794
|
+
await emitKey(chars[i]);
|
|
795
|
+
const got = landed(before, await activeValue());
|
|
796
|
+
await sleep(rand(140, 340));
|
|
797
|
+
await backspaces(got === null ? 2 : Math.max(0, Math.min(2, got)));
|
|
798
|
+
await sleep(rand(80, 200));
|
|
799
|
+
return false;
|
|
800
|
+
}
|
|
801
|
+
return false;
|
|
802
|
+
};
|
|
803
|
+
// What the typo gate needs about the field keys go to: the caller's description, else the focused
|
|
804
|
+
// element read in the isolated world.
|
|
805
|
+
const typingField = async (field) => {
|
|
806
|
+
if (field !== null && field !== undefined)
|
|
807
|
+
return field;
|
|
808
|
+
try {
|
|
809
|
+
return (await worldFor(page).evaluate(ISO_ACTIVE_FIELD)) ?? null;
|
|
810
|
+
}
|
|
811
|
+
catch {
|
|
812
|
+
return null;
|
|
813
|
+
}
|
|
814
|
+
};
|
|
471
815
|
// Type text key-by-key with human timing. Each char goes through the native keyboard (trusted),
|
|
472
816
|
// so this stays isTrusted===true.
|
|
473
817
|
//
|
|
@@ -479,11 +823,16 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
479
823
|
// Fast pairs ROLL OVER: the next key goes down before the previous one comes up. One press() per
|
|
480
824
|
// character never overlaps (0 of 32 keydowns, measured on r32/r33), a keystroke-dynamics tell;
|
|
481
825
|
// real typists overlap many fast pairs. The share is the persona's rolloverRate, stable per seed.
|
|
826
|
+
//
|
|
827
|
+
// Fat-finger typos are planned by forms.planTypo and never happen where a slip is a failure rather
|
|
828
|
+
// than a slip (passwords, e-mail, numbers, phone numbers, URLs, one-time codes).
|
|
482
829
|
const rollRate = rolloverRate(persona);
|
|
483
|
-
const humanTypeText = async (text) => {
|
|
830
|
+
const humanTypeText = async (text, field, typos = true) => {
|
|
484
831
|
const chars = Array.from(text);
|
|
485
832
|
const n = chars.length;
|
|
486
833
|
let shift = false;
|
|
834
|
+
const rate = typos ? typoRate() : 0;
|
|
835
|
+
const allow = rate > 0 && typosAllowed(await typingField(field));
|
|
487
836
|
try {
|
|
488
837
|
for (let i = 0; i < n; i++) {
|
|
489
838
|
let ch = chars[i];
|
|
@@ -493,17 +842,18 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
493
842
|
shift = true;
|
|
494
843
|
await sleep(rand(35, 120));
|
|
495
844
|
}
|
|
496
|
-
// occasional fat-finger on unshifted
|
|
845
|
+
// occasional fat-finger on unshifted letters and digits, then notice + correct
|
|
497
846
|
let typo = false;
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
847
|
+
let consumed = false;
|
|
848
|
+
if (allow && !need && !shift && typoEligible(ch)) {
|
|
849
|
+
const tp = planTypo(chars, i, Math.random, rate);
|
|
850
|
+
if (tp !== null) {
|
|
851
|
+
typo = true;
|
|
852
|
+
try {
|
|
853
|
+
consumed = await doTypo(chars, i, tp);
|
|
854
|
+
}
|
|
855
|
+
catch { /* typo path best-effort */ }
|
|
505
856
|
}
|
|
506
|
-
catch { /* typo path best-effort */ }
|
|
507
857
|
}
|
|
508
858
|
// Rollover of ch and the next char: unshifted single-key pairs only, never a key with itself,
|
|
509
859
|
// never after a typo. keyboard.down inserts the character exactly as press does, so the typed
|
|
@@ -541,7 +891,7 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
541
891
|
ch = nx; // nx is typed; the cadence below runs as if after it
|
|
542
892
|
}
|
|
543
893
|
}
|
|
544
|
-
if (!rolled) {
|
|
894
|
+
if (!rolled && !consumed) {
|
|
545
895
|
try {
|
|
546
896
|
await emitKey(ch);
|
|
547
897
|
}
|
|
@@ -558,7 +908,7 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
558
908
|
if (Math.random() < 0.06)
|
|
559
909
|
await sleep(rand(180, 450)); // brief thinking pause
|
|
560
910
|
else
|
|
561
|
-
await sleep(
|
|
911
|
+
await sleep(flightMs());
|
|
562
912
|
if (/\s/.test(ch))
|
|
563
913
|
await sleep(rand(20, 100)); // slight extra pause at word boundaries
|
|
564
914
|
}
|
|
@@ -590,7 +940,7 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
590
940
|
kbWarmed = true;
|
|
591
941
|
await ambient(rand(280, 620));
|
|
592
942
|
};
|
|
593
|
-
keyboard.type = async (
|
|
943
|
+
keyboard.type = (text) => locked(async () => { await keyboardWarmup(); await humanTypeText(text); });
|
|
594
944
|
// keyboard.press with the persona's hold, unless the caller specified their own.
|
|
595
945
|
//
|
|
596
946
|
// WHY THIS EXISTS. keyboard.type was humanised from the start and keyboard.press was not, which
|
|
@@ -601,261 +951,1071 @@ export async function attachHumanize(browser, page, opts) {
|
|
|
601
951
|
// session sees the press path — so one un-humanised call undid the whole keyboard persona.
|
|
602
952
|
//
|
|
603
953
|
// The caller's own `delay` wins: press(key, {delay}) is the documented way to hold a key for a
|
|
604
|
-
// specific time, and silently overriding it would break that. Only the DEFAULT changes
|
|
605
|
-
|
|
954
|
+
// specific time, and silently overriding it would break that. Only the DEFAULT changes -- and
|
|
955
|
+
// `delay: 0` IS Playwright's default ("no hold"), so it, like any non-positive value, gets the
|
|
956
|
+
// persona's hold too (a 0 ms key hold is the tell this wrapper exists to remove).
|
|
957
|
+
keyboard.press = (key, options = {}) => locked(async () => {
|
|
606
958
|
await keyboardWarmup();
|
|
607
|
-
const opts = options && typeof options === "object" ? options : {};
|
|
608
|
-
if (opts.delay ===
|
|
959
|
+
const opts = { ...(options && typeof options === "object" ? options : {}) };
|
|
960
|
+
if (!(typeof opts.delay === "number" && opts.delay > 0))
|
|
609
961
|
opts.delay = keyDwell(persona);
|
|
610
962
|
return nativeKbPress(key, opts);
|
|
611
|
-
};
|
|
963
|
+
});
|
|
612
964
|
// ------------------------------------------------------- page-level targeted helpers
|
|
613
|
-
//
|
|
614
|
-
|
|
615
|
-
const
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
965
|
+
// Points tried when resolving a control through the browser's hit test: a floating label or an icon
|
|
966
|
+
// often sits over a field's CENTRE, so the edges get a chance too.
|
|
967
|
+
const probePoints = [[0.5, 0.5], [0.82, 0.5], [0.18, 0.5], [0.5, 0.8], [0.5, 0.2]];
|
|
968
|
+
// The control filling `box` (Playwright's resolved box for a locator), read in the isolated world
|
|
969
|
+
// through the browser's own hit test: works for every Playwright selector (getByRole, getByLabel,
|
|
970
|
+
// `>> nth=`, .filter()) where document.querySelector threw and dropped the call to stock input. The
|
|
971
|
+
// element found must fill the SAME box, or it is not the one the locator means; null when no probe
|
|
972
|
+
// point proves that. `probe` records the point that proved it.
|
|
973
|
+
const describe = async (box, want = ISO_FORM_CONTROL) => {
|
|
974
|
+
if (!box || box.width <= 0 || box.height <= 0)
|
|
620
975
|
return null;
|
|
621
|
-
|
|
622
|
-
|
|
976
|
+
const w = worldFor(page);
|
|
977
|
+
const scroll = await w.scroll(); // once: the hit test wants document coordinates
|
|
978
|
+
for (const [fx, fy] of probePoints) {
|
|
979
|
+
const nid = await w.nodeAt(box.x + box.width * fx, box.y + box.height * fy, scroll);
|
|
980
|
+
if (nid === undefined)
|
|
981
|
+
continue;
|
|
982
|
+
const info = await w.callOnNode(nid, ISO_DESCRIBE, { want });
|
|
983
|
+
if (!info || !info.rect)
|
|
984
|
+
continue;
|
|
985
|
+
const [rx, ry, rw, rh] = info.rect;
|
|
986
|
+
if (Math.abs(rx - box.x) <= 1.5 && Math.abs(ry - box.y) <= 1.5
|
|
987
|
+
&& Math.abs(rw - box.width) <= 1.5 && Math.abs(rh - box.height) <= 1.5) {
|
|
988
|
+
info.node = nid;
|
|
989
|
+
// The point that PROVED the control: when the centre is covered (a floating label), this is
|
|
990
|
+
// where a click actually reaches it.
|
|
991
|
+
info.probe = [fx, fy];
|
|
992
|
+
return info;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
return null;
|
|
996
|
+
};
|
|
997
|
+
// A click point the hit test proved reaches the control, when its centre did not; null when the
|
|
998
|
+
// centre itself was fine (then the persona's own point is used, as for any click).
|
|
999
|
+
const provenPoint = (box, info) => {
|
|
1000
|
+
if (!info || !info.probe || (info.probe[0] === 0.5 && info.probe[1] === 0.5))
|
|
1001
|
+
return null;
|
|
1002
|
+
const [fx, fy] = info.probe;
|
|
1003
|
+
const x = box.x + box.width * fx + gauss() * 1.5;
|
|
1004
|
+
const y = box.y + box.height * fy + gauss() * 1.0;
|
|
1005
|
+
return [clamp(x, box.x + 1, box.x + box.width - 1), clamp(y, box.y + 1, box.y + box.height - 1)];
|
|
1006
|
+
};
|
|
1007
|
+
// Describe the SAME element again, from the node the first hit test found: no new Playwright handle
|
|
1008
|
+
// (see boxOf), so nothing the page can see. null if it is gone.
|
|
1009
|
+
const redescribe = async (info, want = ISO_FORM_CONTROL) => {
|
|
1010
|
+
if (info.node === undefined)
|
|
1011
|
+
return null;
|
|
1012
|
+
const again = await worldFor(page).callOnNode(info.node, ISO_DESCRIBE, { want });
|
|
1013
|
+
if (!again)
|
|
1014
|
+
return null;
|
|
1015
|
+
again.node = info.node;
|
|
1016
|
+
again.probe = info.probe ?? [0.5, 0.5];
|
|
1017
|
+
return again;
|
|
1018
|
+
};
|
|
1019
|
+
const inView = async (box) => {
|
|
1020
|
+
const vp = await viewport();
|
|
1021
|
+
return box.x >= 0 && box.y >= 0 && box.x + box.width <= vp.width && box.y + box.height <= vp.height;
|
|
1022
|
+
};
|
|
1023
|
+
const rectBox = (r) => {
|
|
1024
|
+
if (!r || !Array.isArray(r.rect))
|
|
1025
|
+
return null;
|
|
1026
|
+
const [x, y, width, height] = r.rect;
|
|
1027
|
+
return width > 0 && height > 0 ? { x, y, width, height } : null;
|
|
1028
|
+
};
|
|
1029
|
+
// Does a press at viewport (x, y) reach the element filling `box`? true / false, or null when it
|
|
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) => {
|
|
1035
|
+
const w = worldFor(page);
|
|
1036
|
+
const nid = await w.nodeAt(x, y);
|
|
1037
|
+
if (nid === undefined)
|
|
1038
|
+
return null;
|
|
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);
|
|
1043
|
+
return res === undefined || res === null ? null : Boolean(res);
|
|
1044
|
+
};
|
|
1045
|
+
// Does the element filling `box` own its centre -- not clipped by a scrolling container, not
|
|
1046
|
+
// covered? The browser's own hit test, read in the isolated world.
|
|
1047
|
+
const hitOk = async (box, sel = null) => (await hitInBox(box, box.x + box.width / 2, box.y + box.height / 2, sel)) === true;
|
|
1048
|
+
// The box of a plain-CSS selector's first match, read (and scrolled into view) in the isolated
|
|
1049
|
+
// world: not even the one boundingBox() handle, and its textContent preview, is made. Used only
|
|
1050
|
+
// when Playwright counts exactly the elements the browser's own querySelectorAll finds --
|
|
1051
|
+
// Playwright's CSS also pierces open shadow roots, so equal counts mean the same elements in the same
|
|
1052
|
+
// order. null otherwise (getByRole, text=, `>>` chains, :has-text()...: then Playwright's boundingBox).
|
|
1053
|
+
const cssBox = async (selector) => {
|
|
1054
|
+
if (typeof selector !== "string" || !selector)
|
|
1055
|
+
return null;
|
|
1056
|
+
const w = worldFor(page);
|
|
1057
|
+
const r = await w.evaluate(ISO_CSS_BOX, { sel: selector, scroll: false });
|
|
1058
|
+
if (!r || !r.n)
|
|
1059
|
+
return null;
|
|
1060
|
+
try {
|
|
1061
|
+
if ((await page.locator(selector).count()) !== r.n)
|
|
1062
|
+
return null;
|
|
1063
|
+
}
|
|
1064
|
+
catch {
|
|
1065
|
+
return null;
|
|
1066
|
+
}
|
|
1067
|
+
let box = rectBox(r);
|
|
1068
|
+
if (box && !((await inView(box)) && (await hitOk(box, selector)))) {
|
|
1069
|
+
// scrollIntoViewIfNeeded also scrolls a scrolling container that clips it, and does nothing
|
|
1070
|
+
// when it already shows (then the cover is an overlay: the click's own check)
|
|
1071
|
+
box = rectBox(await w.evaluate(ISO_CSS_BOX, { sel: selector, scroll: true }));
|
|
1072
|
+
}
|
|
1073
|
+
return box;
|
|
1074
|
+
};
|
|
1075
|
+
// Scroll the WINDOW so `box` sits centred where it was off-screen, the way Playwright's scroll
|
|
1076
|
+
// centres it, and return where the box is now (moved by exactly the scrolled distance) -- or null.
|
|
1077
|
+
// Needs no handle: the box is already known.
|
|
1078
|
+
const scrollWindowTo = async (box) => {
|
|
1079
|
+
const vp = await viewport();
|
|
1080
|
+
if (box.width > vp.width || box.height > vp.height)
|
|
623
1081
|
return null;
|
|
624
|
-
|
|
625
|
-
const
|
|
626
|
-
|
|
1082
|
+
const cx = box.x + box.width / 2, cy = box.y + box.height / 2;
|
|
1083
|
+
const dx = box.x < 0 || box.x + box.width > vp.width ? cx - vp.width / 2 : 0;
|
|
1084
|
+
const dy = box.y < 0 || box.y + box.height > vp.height ? cy - vp.height / 2 : 0;
|
|
1085
|
+
const moved = await worldFor(page).evaluate(ISO_SCROLL_BY, [dx, dy]);
|
|
1086
|
+
if (!Array.isArray(moved) || moved.length !== 2)
|
|
1087
|
+
return null;
|
|
1088
|
+
return { x: box.x - Number(moved[0]), y: box.y - Number(moved[1]), width: box.width, height: box.height };
|
|
1089
|
+
};
|
|
1090
|
+
// Wait the way Playwright would, with the CALLER's timeout (undefined = the page default; a
|
|
1091
|
+
// hard-coded 30 s used to override setDefaultTimeout both ways), and return the box, in view. With
|
|
1092
|
+
// `force` Playwright skips actionability, so nothing is waited for: a hidden target goes native at
|
|
1093
|
+
// once instead of timing out where the native call would have acted.
|
|
1094
|
+
//
|
|
1095
|
+
// Each Playwright call that resolves the element to a handle (waitFor, scrollIntoViewIfNeeded,
|
|
1096
|
+
// boundingBox) makes a MAIN-world handle whose preview reads the element's textContent where the
|
|
1097
|
+
// page can see it: verifier B counted 4 such reads per humanized click and 7 per textarea fill. So
|
|
1098
|
+
// each is made only when needed: the wait only for an element not visible yet (isVisible runs in
|
|
1099
|
+
// Playwright's utility world), the scroll only for a box not already inside the viewport, and a
|
|
1100
|
+
// plain-CSS `selector` needs no handle at all (cssBox). What remains, for other selectors, is the
|
|
1101
|
+
// one boundingBox.
|
|
1102
|
+
const boxOf = async (loc, timeout, force = false, selector = null) => {
|
|
1103
|
+
if (!(await loc.isVisible())) {
|
|
1104
|
+
if (force)
|
|
1105
|
+
throw new NativeSignal();
|
|
1106
|
+
await loc.waitFor({ state: "visible", timeout });
|
|
1107
|
+
}
|
|
1108
|
+
const css = await cssBox(selector);
|
|
1109
|
+
if (css)
|
|
1110
|
+
return css;
|
|
1111
|
+
let box = await loc.boundingBox();
|
|
1112
|
+
if (box && !((await inView(box)) && (await hitOk(box, selector)))) {
|
|
1113
|
+
// Off-screen: scroll the window to it and prove it shows there (hit test), with no new handle.
|
|
1114
|
+
// Clipped by a scrolling container, covered, or the window scroll did not bring it out:
|
|
1115
|
+
// Playwright's scroll, which also scrolls containers, and a fresh box.
|
|
1116
|
+
const moved = !(await inView(box)) ? await scrollWindowTo(box) : null;
|
|
1117
|
+
if (moved && (await inView(moved)) && (await hitOk(moved, selector)))
|
|
1118
|
+
return moved;
|
|
1119
|
+
await loc.scrollIntoViewIfNeeded({ timeout });
|
|
1120
|
+
box = await loc.boundingBox();
|
|
1121
|
+
}
|
|
1122
|
+
return box;
|
|
1123
|
+
};
|
|
1124
|
+
// Does `loc` hold the keyboard focus? Asked through Playwright's utility-world selector engine
|
|
1125
|
+
// (`:focus`), so it works for every element and every locator and the page sees nothing. The
|
|
1126
|
+
// isolated hit test only knew form controls, so a focused BUTTON read as unfocused and got clicked
|
|
1127
|
+
// before a press -- two activations.
|
|
1128
|
+
const focused = async (loc) => {
|
|
1129
|
+
try {
|
|
1130
|
+
return (await loc.and(page.locator(":focus")).count()) > 0;
|
|
1131
|
+
}
|
|
1132
|
+
catch {
|
|
1133
|
+
return false;
|
|
1134
|
+
}
|
|
1135
|
+
};
|
|
1136
|
+
const isFocused = (selector) => focused(page.locator(selector).first());
|
|
1137
|
+
// {box, info} when `loc` is a visible text-entry control a person would click into before typing,
|
|
1138
|
+
// else null. Anything else -- a button, a link, a hidden helper textarea, a slider -- is left to
|
|
1139
|
+
// Playwright, which focuses it without a click (a click on a button activates it; on a slider it
|
|
1140
|
+
// moves it).
|
|
1141
|
+
const textEntry = async (loc, timeout, selector = null) => {
|
|
1142
|
+
if (!(await loc.isVisible()))
|
|
627
1143
|
return null;
|
|
628
|
-
box =
|
|
629
|
-
|
|
1144
|
+
const box = await boxOf(loc, timeout, false, selector);
|
|
1145
|
+
const info = await describe(box);
|
|
1146
|
+
if (box && info && classifyField(info.tag, info.type, info.ce) === "text")
|
|
1147
|
+
return { box, info };
|
|
1148
|
+
return null;
|
|
630
1149
|
};
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
1150
|
+
// Click into the control filling `box` (resolved, visible and in view). `at`: the point the hit test
|
|
1151
|
+
// proved when the centre is covered (a floating label); else the persona's point. Stability is
|
|
1152
|
+
// checked in the isolated world -- the control must still fill the same box 50 ms later (an
|
|
1153
|
+
// animation in flight goes native) -- not by a second boundingBox(), which the page could see (see
|
|
1154
|
+
// boxOf).
|
|
1155
|
+
//
|
|
1156
|
+
// A control no probe point reaches -- a label or an icon covers all of it -- is clicked anyway, where
|
|
1157
|
+
// a person clicks (a floating label passes the focus on), and the caller checks that the caret
|
|
1158
|
+
// arrived. Its stability then comes from a fresh box: the isolated CSS read, else boundingBox().
|
|
1159
|
+
// Requiring the hit test there sent a fully labelled field to Playwright's insertText (found by the
|
|
1160
|
+
// .NET port).
|
|
1161
|
+
const focusClick = async (box, at = null, loc = null, selector = null) => {
|
|
1162
|
+
if (!box)
|
|
634
1163
|
return false;
|
|
1164
|
+
await sleep(50);
|
|
1165
|
+
const info = await describe(box);
|
|
1166
|
+
if (!info) {
|
|
1167
|
+
let again = selector ? await cssBox(selector) : null;
|
|
1168
|
+
if (!again && loc)
|
|
1169
|
+
again = await loc.boundingBox();
|
|
1170
|
+
if (!again || Math.abs(again.x - box.x) > 1 || Math.abs(again.y - box.y) > 1)
|
|
1171
|
+
return false;
|
|
1172
|
+
}
|
|
1173
|
+
else if (info.disabled) {
|
|
1174
|
+
return false;
|
|
1175
|
+
}
|
|
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
|
|
635
1178
|
await glide(pt.x, pt.y);
|
|
636
|
-
await
|
|
637
|
-
await nativeClick(pt.x, pt.y, { delay: clickHold(persona) });
|
|
1179
|
+
await pressHere(pt.x, pt.y);
|
|
638
1180
|
return true;
|
|
639
1181
|
};
|
|
1182
|
+
// Ctrl+A (Cmd+A on a Mac engine) the way a hand does it: the modifier goes down a moment before the
|
|
1183
|
+
// A, the A is held, the modifier comes up just after. Sent as one Playwright chord the modifier and
|
|
1184
|
+
// the A went down within ~1 ms of each other. ControlOrMeta lets Playwright pick the chord the
|
|
1185
|
+
// ENGINE's editing honours: a Meta+A sent to a Linux engine for a macOS persona selects nothing.
|
|
1186
|
+
const selectAll = async () => {
|
|
1187
|
+
if (!nativeKbDown || !nativeKbUp) {
|
|
1188
|
+
await key("ControlOrMeta+a");
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
await nativeKbDown("ControlOrMeta");
|
|
1192
|
+
try {
|
|
1193
|
+
await sleep(rand(40, 110));
|
|
1194
|
+
await nativeKbPress("a", { delay: keyDwell(persona) });
|
|
1195
|
+
await sleep(rand(20, 80));
|
|
1196
|
+
}
|
|
1197
|
+
finally {
|
|
1198
|
+
await nativeKbUp("ControlOrMeta");
|
|
1199
|
+
}
|
|
1200
|
+
};
|
|
1201
|
+
// Select all, then Backspace -- each key HELD like a finger holds it. The old clear sent Control+a
|
|
1202
|
+
// and Backspace with ~0 ms holds, which is exactly what a key-dwell check flags. If the field is
|
|
1203
|
+
// still not empty (`read` -- the field's value -- says so), it is emptied from the end, one
|
|
1204
|
+
// Backspace per character.
|
|
1205
|
+
const clearFocused = async (read = activeValue) => {
|
|
1206
|
+
await selectAll();
|
|
1207
|
+
await sleep(rand(60, 160));
|
|
1208
|
+
await key("Backspace");
|
|
1209
|
+
await sleep(rand(60, 160));
|
|
1210
|
+
const left = await read();
|
|
1211
|
+
if (left) {
|
|
1212
|
+
const len = Array.from(left).length;
|
|
1213
|
+
if (len > 64)
|
|
1214
|
+
throw new Error("field would not clear");
|
|
1215
|
+
await key("End");
|
|
1216
|
+
await sleep(rand(80, 180));
|
|
1217
|
+
await key("Backspace", len, [55, 120]);
|
|
1218
|
+
await sleep(rand(60, 160));
|
|
1219
|
+
if (await read())
|
|
1220
|
+
throw new Error("field would not clear");
|
|
1221
|
+
}
|
|
1222
|
+
};
|
|
640
1223
|
const nativePageType = page.type ? page.type.bind(page) : null;
|
|
641
1224
|
const nativePageFill = page.fill.bind(page);
|
|
642
1225
|
const nativePagePress = page.press.bind(page);
|
|
643
1226
|
const nativePageDblclick = page.dblclick.bind(page);
|
|
644
|
-
const
|
|
645
|
-
|
|
1227
|
+
const nativePageClick = page.click.bind(page);
|
|
1228
|
+
const nativePageHover = page.hover.bind(page);
|
|
1229
|
+
const nativePageSelect = typeof page.selectOption === "function" ? page.selectOption.bind(page) : null;
|
|
1230
|
+
const nativeType = (selector, text, options) => nativePageType ? nativePageType(selector, text, options) : nativePageFill(selector, text, options);
|
|
1231
|
+
// Make `selector` the focus the way a person does (a click), unless it already is. Only text-entry
|
|
1232
|
+
// controls are clicked; for anything else, and when the click did not actually put the caret there
|
|
1233
|
+
// (an overlay took it), the caller goes native.
|
|
1234
|
+
const clickInto = async (selector, timeout) => {
|
|
1235
|
+
const loc = page.locator(selector).first();
|
|
1236
|
+
if (await focused(loc))
|
|
1237
|
+
return;
|
|
1238
|
+
const te = await textEntry(loc, timeout, selector);
|
|
1239
|
+
if (!te || !(await focusClick(te.box, provenPoint(te.box, te.info), loc, selector)) || !(await focused(loc))) {
|
|
1240
|
+
throw new NativeSignal();
|
|
1241
|
+
}
|
|
1242
|
+
await sleep(rand(40, 120));
|
|
1243
|
+
};
|
|
1244
|
+
const htype = async (selector, text, options) => {
|
|
646
1245
|
try {
|
|
647
|
-
|
|
648
|
-
if (!(await isFocused(selector))) {
|
|
649
|
-
if (!(await focusClick(selector, timeout))) {
|
|
650
|
-
return nativePageType ? nativePageType(selector, text, options) : nativePageFill(selector, text, options);
|
|
651
|
-
}
|
|
652
|
-
await sleep(rand(40, 120));
|
|
653
|
-
}
|
|
1246
|
+
await clickInto(selector, options?.timeout);
|
|
654
1247
|
await humanTypeText(text);
|
|
1248
|
+
return;
|
|
655
1249
|
}
|
|
656
|
-
catch {
|
|
657
|
-
|
|
1250
|
+
catch (e) {
|
|
1251
|
+
if (isTimeout(e))
|
|
1252
|
+
throw e;
|
|
658
1253
|
}
|
|
1254
|
+
// outside the try: a native call that raises must raise ONCE, not be retried by the handler
|
|
1255
|
+
return nativeType(selector, text, options);
|
|
659
1256
|
};
|
|
660
|
-
page.
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
1257
|
+
page.type = apiErrors("page.type", (selector, text, options = {}) => locked(() => htype(selector, text, options)));
|
|
1258
|
+
// Type `value` into a text control and PROVE it landed. The value is read through Playwright's
|
|
1259
|
+
// utility world (`inputValue`): it sees the control the locator means, wherever focus is, and the page
|
|
1260
|
+
// cannot observe it. A field whose value cannot be read, or a click that did not put the caret in it
|
|
1261
|
+
// (an overlay took it), is left to Playwright -- "could not check" is never treated as success.
|
|
1262
|
+
const fillText = async (loc, box, value, info, selector = null) => {
|
|
1263
|
+
const isCe = Boolean(info && info.ce)
|
|
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;
|
|
1281
|
+
if (!(await focusClick(box, box ? provenPoint(box, info) : null, loc, selector)))
|
|
1282
|
+
return false;
|
|
1283
|
+
await sleep(rand(40, 120));
|
|
1284
|
+
if (!(await focused(loc)))
|
|
1285
|
+
return false;
|
|
1286
|
+
const read = async () => {
|
|
668
1287
|
try {
|
|
669
|
-
await
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
1288
|
+
return await loc.inputValue({ timeout: COVER_RETRY_MS });
|
|
1289
|
+
}
|
|
1290
|
+
catch {
|
|
1291
|
+
return null;
|
|
1292
|
+
}
|
|
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);
|
|
673
1301
|
}
|
|
674
|
-
|
|
675
|
-
|
|
1302
|
+
};
|
|
1303
|
+
if (isCe) {
|
|
1304
|
+
const cur = info ? info.value : null;
|
|
1305
|
+
if (cur)
|
|
1306
|
+
await clearFocused();
|
|
1307
|
+
await put(want);
|
|
1308
|
+
return true; // contenteditable: Playwright's fill sets text the same way; no value to read
|
|
676
1309
|
}
|
|
677
|
-
|
|
678
|
-
|
|
1310
|
+
const cur = await read();
|
|
1311
|
+
if (cur === null)
|
|
1312
|
+
return false;
|
|
1313
|
+
if (cur)
|
|
1314
|
+
await clearFocused(read); // a person does not select-all an empty field
|
|
1315
|
+
await put(want);
|
|
1316
|
+
let got = await read();
|
|
1317
|
+
if (got === null)
|
|
1318
|
+
return false;
|
|
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
|
|
1325
|
+
// Wrong value (an autocompleter interfered, a key was lost): one clean retry without slips, then
|
|
1326
|
+
// give the field to Playwright -- a correct value beats pretty input.
|
|
1327
|
+
await clearFocused(read);
|
|
1328
|
+
await put(want, false);
|
|
1329
|
+
got = await read();
|
|
1330
|
+
return got !== null && (got === want || formattedEqual(got, want) || maskKept(got, want, ...limit));
|
|
1331
|
+
};
|
|
1332
|
+
// A range input: drag the thumb (or click the track) to the value, then arrow-nudge to it exactly.
|
|
1333
|
+
// Typing digits into a slider -- what fill() did before -- changes nothing, and the click before it
|
|
1334
|
+
// left the thumb wherever it landed, with no error.
|
|
1335
|
+
const fillRange = async (loc, box, info0, value) => {
|
|
1336
|
+
const info = info0 ?? {
|
|
1337
|
+
min: await loc.getAttribute("min"), max: await loc.getAttribute("max"),
|
|
1338
|
+
step: await loc.getAttribute("step"), value: await loc.inputValue(), dir: "ltr", wm: "",
|
|
1339
|
+
};
|
|
1340
|
+
// Off the step, out of range, not a number: Playwright raises "Malformed value" for these, so does
|
|
1341
|
+
// this route (by leaving them to it) -- never a silently snapped or clamped value.
|
|
1342
|
+
if (!rangeValueOk(value, info.min, info.max, info.step))
|
|
1343
|
+
return false;
|
|
1344
|
+
if ((info.wm || "").startsWith("vertical"))
|
|
1345
|
+
return false;
|
|
1346
|
+
const rtl = info.dir === "rtl";
|
|
1347
|
+
const [lo, hi, step] = rangeAttrs(info.min, info.max, info.step);
|
|
1348
|
+
const target = rangeSanitize(value, info.min, info.max, info.step);
|
|
1349
|
+
const now = async () => {
|
|
1350
|
+
const f = pyFloat(await loc.inputValue());
|
|
1351
|
+
return f === null || Number.isNaN(f) ? null : f;
|
|
1352
|
+
};
|
|
1353
|
+
const cur = await now();
|
|
1354
|
+
if (cur !== null && numericEqual(cur, target))
|
|
1355
|
+
return true;
|
|
1356
|
+
let [tx, ty] = rangePoint(box, rangeFraction(target, lo, hi), rtl);
|
|
1357
|
+
ty += gauss() * box.height * 0.08;
|
|
1358
|
+
if (cur !== null && Math.random() < 0.65) {
|
|
1359
|
+
// Grab the thumb where it is and slide it: what most hands do with a slider.
|
|
1360
|
+
let [cx, cy] = rangePoint(box, rangeFraction(cur, lo, hi), rtl);
|
|
1361
|
+
cy += gauss() * box.height * 0.08;
|
|
1362
|
+
await glide(cx, cy, { targetW: 16 });
|
|
1363
|
+
await prePress();
|
|
1364
|
+
const { grabMs, releaseMs } = dragDwell(persona);
|
|
1365
|
+
await nativeDown();
|
|
1366
|
+
st.held = true;
|
|
1367
|
+
try {
|
|
1368
|
+
await sleep(grabMs);
|
|
1369
|
+
await glide(tx, ty, { settle: true });
|
|
1370
|
+
await sleep(releaseMs);
|
|
1371
|
+
}
|
|
1372
|
+
finally {
|
|
1373
|
+
await nativeUp();
|
|
1374
|
+
st.held = false;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
else {
|
|
1378
|
+
await glide(tx, ty, { targetW: 16 });
|
|
1379
|
+
await pressHere(tx, ty);
|
|
1380
|
+
}
|
|
1381
|
+
for (let round = 0; round < 3; round++) {
|
|
1382
|
+
const v = await now();
|
|
1383
|
+
if (v === null)
|
|
1384
|
+
return false;
|
|
1385
|
+
if (numericEqual(v, target))
|
|
1386
|
+
return true;
|
|
1387
|
+
let [k, n] = rangeNudge(v, target, step);
|
|
1388
|
+
if (k === null || n <= 0 || n > 40)
|
|
1389
|
+
break;
|
|
1390
|
+
if (rtl)
|
|
1391
|
+
k = k === "ArrowRight" ? "ArrowLeft" : "ArrowRight";
|
|
1392
|
+
await sleep(rand(150, 350));
|
|
1393
|
+
await key(k, n);
|
|
1394
|
+
}
|
|
1395
|
+
const v = await now();
|
|
1396
|
+
return v !== null && numericEqual(v, target);
|
|
1397
|
+
};
|
|
1398
|
+
// A native date / time / month / week / datetime-local editor: click into its first field and type
|
|
1399
|
+
// each field in the order the editor itself shows them (read from its UA shadow tree over CDP --
|
|
1400
|
+
// the order follows the browser, not the persona, so it is never assumed).
|
|
1401
|
+
const fillDatetime = async (loc, box, info, value) => {
|
|
1402
|
+
if (!info || !info.node)
|
|
1403
|
+
return false;
|
|
1404
|
+
const kind = info.type;
|
|
1405
|
+
const w = worldFor(page);
|
|
1406
|
+
const tree = await w.send("DOM.describeNode", { backendNodeId: info.node, depth: -1, pierce: true });
|
|
1407
|
+
if (!tree || !tree.node)
|
|
1408
|
+
return false;
|
|
1409
|
+
const segs = segmentNodesFromTree(tree.node);
|
|
1410
|
+
const plan = planDatetime(kind, segs.map(([s]) => s), value, info.value);
|
|
1411
|
+
if (!plan || plan.length === 0 || segs.length === 0)
|
|
1412
|
+
return false;
|
|
1413
|
+
let px = null, py = 0, fw = 12.0;
|
|
1414
|
+
const bm = segs[0][1] ? await w.send("DOM.getBoxModel", { backendNodeId: segs[0][1] }) : undefined;
|
|
1415
|
+
if (bm && bm.model) {
|
|
1416
|
+
const c = bm.model.content;
|
|
1417
|
+
const xs = [c[0], c[2], c[4], c[6]], ys = [c[1], c[3], c[5], c[7]];
|
|
1418
|
+
const x0 = Math.min(...xs), y0 = Math.min(...ys), x1 = Math.max(...xs), y1 = Math.max(...ys);
|
|
1419
|
+
// trust the field box only when it lies inside the input's own box
|
|
1420
|
+
if (box.x - 1 <= x0 && x0 < x1 && x1 <= box.x + box.width + 1
|
|
1421
|
+
&& box.y - 1 <= y0 && y0 < y1 && y1 <= box.y + box.height + 1) {
|
|
1422
|
+
px = rand(x0 + (x1 - x0) * 0.25, x0 + (x1 - x0) * 0.75);
|
|
1423
|
+
py = (y0 + y1) / 2 + gauss() * Math.max(0.5, (y1 - y0) * 0.1);
|
|
1424
|
+
fw = Math.max(6.0, x1 - x0);
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
if (px === null) {
|
|
1428
|
+
px = box.x + Math.min(14.0, box.width * 0.12);
|
|
1429
|
+
py = box.y + box.height / 2;
|
|
1430
|
+
}
|
|
1431
|
+
await glide(px, py, { targetW: fw });
|
|
1432
|
+
await pressHere(px, py);
|
|
1433
|
+
await sleep(rand(150, 350));
|
|
1434
|
+
for (const a of plan) {
|
|
1435
|
+
if (a[0] === "type") {
|
|
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);
|
|
1441
|
+
}
|
|
1442
|
+
else {
|
|
1443
|
+
await key(a[1], a.length > 2 ? a[2] : 1, [70, 170]);
|
|
1444
|
+
}
|
|
1445
|
+
await sleep(rand(60, 220)); // between fields: the eye checks what it typed
|
|
679
1446
|
}
|
|
1447
|
+
return datetimeEqual(kind, await loc.inputValue(), value);
|
|
680
1448
|
};
|
|
681
|
-
|
|
1449
|
+
// Classify through Playwright's utility-world queries when the hit test could not prove the element
|
|
1450
|
+
// (it is covered, or inside a frame): attributes only, nothing the page can see.
|
|
1451
|
+
const classifyFallback = async (loc) => {
|
|
1452
|
+
for (const tag of ["textarea", "select"]) {
|
|
1453
|
+
if (await loc.locator(`xpath=self::${tag}`).count())
|
|
1454
|
+
return classifyField(tag);
|
|
1455
|
+
}
|
|
1456
|
+
if (await loc.locator("xpath=self::input").count())
|
|
1457
|
+
return classifyField("input", await loc.getAttribute("type"));
|
|
1458
|
+
const ce = await loc.getAttribute("contenteditable");
|
|
1459
|
+
return classifyField("div", null, ce !== null && ce.toLowerCase() !== "false");
|
|
1460
|
+
};
|
|
1461
|
+
// fill() classifies the field: text-like is typed (cleared first only when non-empty, then
|
|
1462
|
+
// verified), a range is dragged/clicked and nudged, a date-like editor is typed segment by segment.
|
|
1463
|
+
// color, unfillable types and anything the humane route cannot prove it did right go to Playwright.
|
|
1464
|
+
const hfill = async (selector, value, options) => {
|
|
682
1465
|
try {
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
1466
|
+
if (typeof value === "string") {
|
|
1467
|
+
const timeout = options?.timeout; // undefined: the page's default, as Playwright would use
|
|
1468
|
+
const loc = page.locator(selector).first();
|
|
1469
|
+
const box = await boxOf(loc, timeout, Boolean(options?.force), selector);
|
|
1470
|
+
const info = await describe(box);
|
|
1471
|
+
const kind = info ? classifyField(info.tag, info.type, info.ce) : await classifyFallback(loc);
|
|
1472
|
+
if (!(info && (info.disabled || info.readonly))) {
|
|
1473
|
+
if (kind === "text" && await fillText(loc, box, value, info, selector))
|
|
1474
|
+
return;
|
|
1475
|
+
if (kind === "range" && box && await fillRange(loc, box, info, value))
|
|
1476
|
+
return;
|
|
1477
|
+
if (kind === "datetime" && box && await fillDatetime(loc, box, info, value))
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
690
1481
|
}
|
|
691
|
-
catch {
|
|
692
|
-
|
|
1482
|
+
catch (e) {
|
|
1483
|
+
if (isTimeout(e))
|
|
1484
|
+
throw e;
|
|
1485
|
+
}
|
|
1486
|
+
// Native: color (Playwright sets it -- the OS dialog is no route), unfillable types (Playwright
|
|
1487
|
+
// raises, as before humanize), and anything the humane route could not prove it did right.
|
|
1488
|
+
return nativePageFill(selector, value, options);
|
|
1489
|
+
};
|
|
1490
|
+
page.fill = apiErrors("page.fill", (selector, value, options = {}) => locked(() => hfill(selector, value, options)));
|
|
1491
|
+
page.dblclick = apiErrors("page.dblclick", (selector, options = {}) => locked(() => {
|
|
1492
|
+
const o = { ...(options ?? {}) };
|
|
1493
|
+
if (o.clickCount === undefined)
|
|
1494
|
+
o.clickCount = 2;
|
|
1495
|
+
return targetedClick(nativePageDblclick, selector, o, false, options ?? {});
|
|
1496
|
+
}));
|
|
1497
|
+
const hpress = async (selector, key, options) => {
|
|
1498
|
+
try {
|
|
1499
|
+
// Click into text fields only: a press on a button or a link that is not focused yet is
|
|
1500
|
+
// Playwright's (it focuses without activating); clicking it first activated it twice.
|
|
1501
|
+
await clickInto(selector, options?.timeout);
|
|
1502
|
+
// The persona's hold unless the caller set a real one, for the same reason keyboard.press above
|
|
1503
|
+
// carries one: this is the path locator.press() delegates to.
|
|
1504
|
+
const d = options?.delay;
|
|
1505
|
+
await nativeKbPress(key, { delay: typeof d === "number" && d > 0 ? d : keyDwell(persona) });
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
catch (e) {
|
|
1509
|
+
if (isTimeout(e))
|
|
1510
|
+
throw e;
|
|
1511
|
+
}
|
|
1512
|
+
return nativePagePress(selector, key, options);
|
|
1513
|
+
};
|
|
1514
|
+
page.press = apiErrors("page.press", (selector, key, options = {}) => locked(() => hpress(selector, key, options)));
|
|
1515
|
+
// ------------------------------------------------------------------------- selectOption
|
|
1516
|
+
// Open the drop-down with a click, move its highlight with the planned keys, commit with Enter: one
|
|
1517
|
+
// trusted input+change, the sequence a mouse-and-keyboard user produces. While the popup is open its
|
|
1518
|
+
// keys never reach the page (measured on r37: value unchanged until Enter). The opening click is the
|
|
1519
|
+
// full humanized click -- including the covered-point check -- at the point the hit test proved when
|
|
1520
|
+
// the centre is covered (`at`, a Playwright `position`), so a floating label over the select's centre
|
|
1521
|
+
// cannot swallow it and leave the letters to change a closed select one per key.
|
|
1522
|
+
const openAndPick = async (selector, plan, at) => {
|
|
1523
|
+
await targetedClick(nativePageClick, selector, at ? { position: at } : {}, false, null);
|
|
1524
|
+
await sleep(rand(220, 480)); // the list opens; the eye finds the option
|
|
1525
|
+
for (const ch of plan.keys) {
|
|
1526
|
+
await nativeKbPress(ch, { delay: keyDwell(persona) });
|
|
1527
|
+
await sleep(Math.max(40, 100 + gauss() * 35)); // well inside type-ahead's 1 s window
|
|
1528
|
+
}
|
|
1529
|
+
const n = plan.arrows;
|
|
1530
|
+
if (n) {
|
|
1531
|
+
await sleep(rand(120, 300));
|
|
1532
|
+
await arrowRun(n > 0 ? "ArrowDown" : "ArrowUp", Math.abs(n));
|
|
693
1533
|
}
|
|
1534
|
+
await sleep(rand(150, 400));
|
|
1535
|
+
await key("Enter");
|
|
1536
|
+
await sleep(rand(80, 160));
|
|
694
1537
|
};
|
|
695
|
-
|
|
1538
|
+
// `n` presses of an arrow key in an OPEN drop-down. A long run is HELD, the way a person crosses a
|
|
1539
|
+
// long list: one press, the keyboard's repeat delay, then auto-repeat at its rate -- Playwright sends
|
|
1540
|
+
// a key that is already down again as an autoRepeat keydown. Pressed one by one at a human ~180 ms
|
|
1541
|
+
// each, 370 rows took 68 s (verifier B). The open popup takes these keys, so the page sees none of
|
|
1542
|
+
// them, only how long the pick took.
|
|
1543
|
+
const arrowRun = async (k, n) => {
|
|
1544
|
+
if (n < 8 || !nativeKbDown || !nativeKbUp) {
|
|
1545
|
+
await key(k, n);
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
await nativeKbDown(k);
|
|
696
1549
|
try {
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
await
|
|
1550
|
+
await sleep(rand(380, 560)); // the repeat delay
|
|
1551
|
+
const gap = rand(28, 36); // ~30 repeats a second
|
|
1552
|
+
for (let j = 0; j < n - 1; j++) {
|
|
1553
|
+
await sleep(gap);
|
|
1554
|
+
await nativeKbDown(k);
|
|
702
1555
|
}
|
|
703
|
-
// The persona's hold, for the same reason keyboard.press above carries one: this is the
|
|
704
|
-
// path locator.press() delegates to, so without it every locator.press in a script emitted
|
|
705
|
-
// a zero-length keypress while the typed text around it looked human.
|
|
706
|
-
await nativeKbPress(key, { delay: keyDwell(persona) });
|
|
707
1556
|
}
|
|
708
|
-
|
|
709
|
-
|
|
1557
|
+
finally {
|
|
1558
|
+
await sleep(rand(20, 60));
|
|
1559
|
+
await nativeKbUp(k); // never leave the key down
|
|
710
1560
|
}
|
|
711
1561
|
};
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
//
|
|
720
|
-
//
|
|
1562
|
+
const listboxRow = async (info, i) => {
|
|
1563
|
+
if (info.node === undefined)
|
|
1564
|
+
return null;
|
|
1565
|
+
return (await worldFor(page).callOnNode(info.node, ISO_LISTBOX_ROW, { i })) ?? null;
|
|
1566
|
+
};
|
|
1567
|
+
// The list box's state once its scroll has come to rest. A wheel notch scrolls the box SMOOTHLY over
|
|
1568
|
+
// a few frames, and a read taken mid-animation shows the row where it was, not where it is going:
|
|
1569
|
+
// the old loop then wheeled the wrong way and oscillated (verifier B: scrollTop 120 -> 240 -> 323 ->
|
|
1570
|
+
// 203 -> 83 -> 0, twice, with the row in view at 120, then a native, untrusted fallback). Two equal
|
|
1571
|
+
// reads 50 ms apart = at rest.
|
|
1572
|
+
const settledRow = async (info, i) => {
|
|
1573
|
+
let prev = await listboxRow(info, i);
|
|
1574
|
+
for (let k = 0; k < 30; k++) {
|
|
1575
|
+
await sleep(50);
|
|
1576
|
+
const cur = await listboxRow(info, i);
|
|
1577
|
+
if (!cur || !prev)
|
|
1578
|
+
return cur;
|
|
1579
|
+
if (Math.abs(cur.st - prev.st) < 0.5 && Math.abs(cur.row[1] - prev.row[1]) < 0.5)
|
|
1580
|
+
return cur;
|
|
1581
|
+
prev = cur;
|
|
1582
|
+
}
|
|
1583
|
+
return prev;
|
|
1584
|
+
};
|
|
1585
|
+
// The visible vertical span [top, bottom] of the row, or null when too little of it shows. A row cut
|
|
1586
|
+
// off by the box edge is still a target a person clicks when most of it shows.
|
|
1587
|
+
const rowSpan = (s) => {
|
|
1588
|
+
const [, y, w, h] = s.row;
|
|
1589
|
+
if (w <= 0 || h <= 0)
|
|
1590
|
+
return null;
|
|
1591
|
+
const top = Math.max(y, s.top), bottom = Math.min(y + h, s.bottom);
|
|
1592
|
+
return bottom - top >= Math.min(h, Math.max(6, h * 0.6)) ? [top, bottom] : null;
|
|
1593
|
+
};
|
|
1594
|
+
// Wheel the list box until row `i` shows: the pointer goes over the box once, then as many notches
|
|
1595
|
+
// as the distance needs -- one at first, to learn how far a notch scrolls this box -- each burst read
|
|
1596
|
+
// only once the scroll has settled. {top, bottom, s}, or null when it cannot be brought into view
|
|
1597
|
+
// (the wheel does not move the box, or it is at its end).
|
|
1598
|
+
const scrollRowIntoView = async (info, i) => {
|
|
1599
|
+
let s = await settledRow(info, i);
|
|
1600
|
+
if (!s)
|
|
1601
|
+
return null;
|
|
1602
|
+
let span = rowSpan(s), per = null, over = false, notches = 0;
|
|
1603
|
+
while (span === null && notches < 60) {
|
|
1604
|
+
const [, y, , h] = s.row;
|
|
1605
|
+
const room = s.bottom - s.top;
|
|
1606
|
+
const margin = rand(2, Math.max(3, Math.min(12, (room - h) * 0.4)));
|
|
1607
|
+
const need = y + h > s.bottom ? (y + h) - s.bottom + margin : y - s.top - margin;
|
|
1608
|
+
if ((need > 0 && s.st >= s.max - 0.5) || (need < 0 && s.st <= 0.5))
|
|
1609
|
+
return null;
|
|
1610
|
+
if (!over) {
|
|
1611
|
+
const [bx, , bw] = s.box;
|
|
1612
|
+
await glide(bx + bw * rand(0.3, 0.7), s.top + room * rand(0.3, 0.7), { targetW: Math.max(20, room) });
|
|
1613
|
+
await sleep(rand(80, 200));
|
|
1614
|
+
over = true;
|
|
1615
|
+
}
|
|
1616
|
+
const n = !per ? 1
|
|
1617
|
+
: Math.max(1, Math.min(8, Math.floor(Math.abs(need) / per) + (Math.abs(need) % per > per * 0.25 ? 1 : 0)));
|
|
1618
|
+
const notch = await wheelNotchPx();
|
|
1619
|
+
for (let j = 0; j < n; j++) {
|
|
1620
|
+
await nativeWheel(0, need > 0 ? notch : -notch);
|
|
1621
|
+
notches++;
|
|
1622
|
+
if (j < n - 1)
|
|
1623
|
+
await sleep(rand(30, 70));
|
|
1624
|
+
}
|
|
1625
|
+
const before = s.st;
|
|
1626
|
+
const next = await settledRow(info, i);
|
|
1627
|
+
if (!next)
|
|
1628
|
+
return null;
|
|
1629
|
+
s = next;
|
|
1630
|
+
const moved = Math.abs(s.st - before);
|
|
1631
|
+
if (moved < 0.5)
|
|
1632
|
+
return null; // the wheel did not scroll the box
|
|
1633
|
+
per = moved / n;
|
|
1634
|
+
span = rowSpan(s);
|
|
1635
|
+
}
|
|
1636
|
+
return span ? { top: span[0], bottom: span[1], s } : null;
|
|
1637
|
+
};
|
|
1638
|
+
// A list box (multiple or size > 1): click the rows -- the first plainly (which also drops any other
|
|
1639
|
+
// selection), the rest with Ctrl/Cmd held, scrolling the box with the wheel to bring a row into view
|
|
1640
|
+
// the way a person does.
|
|
1641
|
+
const selectListbox = async (info, idx) => {
|
|
1642
|
+
const want = [...idx].sort((a, b) => a - b);
|
|
1643
|
+
const opts = info.options ?? [];
|
|
1644
|
+
const selectedNow = opts.map((o, i) => (o.selected ? i : -1)).filter((i) => i >= 0);
|
|
1645
|
+
if (want.length === selectedNow.length && want.every((i, k) => i === selectedNow[k])) {
|
|
1646
|
+
return want.map((i) => opts[i].value);
|
|
1647
|
+
}
|
|
1648
|
+
for (let k = 0; k < want.length; k++) {
|
|
1649
|
+
const got = await scrollRowIntoView(info, want[k]);
|
|
1650
|
+
if (!got)
|
|
1651
|
+
return null;
|
|
1652
|
+
const { top, bottom, s } = got;
|
|
1653
|
+
const r = s.row;
|
|
1654
|
+
const px = rand(r[0] + r[2] * 0.12, r[0] + Math.min(r[2] * 0.6, 140));
|
|
1655
|
+
let py = (top + bottom) / 2 + gauss() * Math.max(0.5, (bottom - top) * 0.12);
|
|
1656
|
+
py = Math.min(Math.max(py, top + 1), bottom - 1);
|
|
1657
|
+
await glide(px, py, { targetW: bottom - top });
|
|
1658
|
+
if (k && nativeKbDown && nativeKbUp) {
|
|
1659
|
+
// ControlOrMeta: Playwright sends the add-to-selection key the ENGINE honours
|
|
1660
|
+
await pressAt(normalizeClickOptions({ modifiers: ["ControlOrMeta"] }));
|
|
1661
|
+
}
|
|
1662
|
+
else {
|
|
1663
|
+
await pressHere(px, py);
|
|
1664
|
+
}
|
|
1665
|
+
await sleep(rand(150, 350));
|
|
1666
|
+
}
|
|
1667
|
+
const final = await listboxRow(info, want[0]);
|
|
1668
|
+
if (!final)
|
|
1669
|
+
return null;
|
|
1670
|
+
const got = final.sel.map((v, i) => (v ? i : -1)).filter((i) => i >= 0);
|
|
1671
|
+
return got.length === want.length && got.every((i, k) => i === want[k]) ? want.map((i) => opts[i].value) : null;
|
|
1672
|
+
};
|
|
1673
|
+
// Pick options the way a person does (see openAndPick / selectListbox). Resolves to the selected
|
|
1674
|
+
// values like Playwright, or null when the humane route cannot apply or cannot be shown to have
|
|
1675
|
+
// worked -- the caller then runs stock selectOption.
|
|
1676
|
+
const selectHumanized = async (selector, values, options) => {
|
|
1677
|
+
const wants = selectWants(values);
|
|
1678
|
+
if (!wants)
|
|
1679
|
+
return null;
|
|
1680
|
+
const loc = page.locator(selector).first();
|
|
1681
|
+
const box = await boxOf(loc, options?.timeout, Boolean(options?.force), selector);
|
|
1682
|
+
const info = await describe(box, "select");
|
|
1683
|
+
if (!box || !info || info.tag !== "select" || info.disabled)
|
|
1684
|
+
return null;
|
|
1685
|
+
const opts = info.options ?? [];
|
|
1686
|
+
const idx = [];
|
|
1687
|
+
for (const w of wants) {
|
|
1688
|
+
const i = wantIndex(opts, w);
|
|
1689
|
+
if (i < 0 || opts[i].disabled)
|
|
1690
|
+
return null; // not there (yet) or disabled: Playwright waits / reports it
|
|
1691
|
+
if (!idx.includes(i))
|
|
1692
|
+
idx.push(i);
|
|
1693
|
+
}
|
|
1694
|
+
if (info.multiple || (info.size ?? 0) > 1) {
|
|
1695
|
+
return selectListbox(info, info.multiple ? idx : [Math.min(...idx)]);
|
|
1696
|
+
}
|
|
1697
|
+
const t = Math.min(...idx); // Playwright: the FIRST option (document order) matching any wanted one
|
|
1698
|
+
if (info.selectedIndex === t)
|
|
1699
|
+
return [opts[t].value];
|
|
1700
|
+
const labels = opts.map((o) => (o.disabled ? null : (o.label || "")));
|
|
1701
|
+
const plan = planSelect(labels, info.selectedIndex ?? -1, t);
|
|
1702
|
+
if (plan === null)
|
|
1703
|
+
return null;
|
|
1704
|
+
const pt = provenPoint(box, info);
|
|
1705
|
+
const at = pt ? { x: pt[0] - box.x - (info.bl || 0), y: pt[1] - box.y - (info.bt || 0) } : null;
|
|
1706
|
+
await openAndPick(selector, plan, at);
|
|
1707
|
+
if ((await loc.inputValue()) === opts[t].value)
|
|
1708
|
+
return [opts[t].value];
|
|
1709
|
+
// One correction from wherever the highlight landed (arrows only), then give up to native.
|
|
1710
|
+
const again = await redescribe(info, "select");
|
|
1711
|
+
if (!again)
|
|
1712
|
+
return null;
|
|
1713
|
+
const fix = planSelect(labels, again.selectedIndex ?? -1, t, 0);
|
|
1714
|
+
if (fix === null)
|
|
1715
|
+
return null;
|
|
1716
|
+
await openAndPick(selector, fix, at);
|
|
1717
|
+
return (await loc.inputValue()) === opts[t].value ? [opts[t].value] : null;
|
|
1718
|
+
};
|
|
1719
|
+
// page.selectOption, humanized: no more script-assigned value with an untrusted change event
|
|
1720
|
+
// (which fails 'interaction-select-change-trust' on clearcotelabs.com/audit).
|
|
721
1721
|
if (nativePageSelect) {
|
|
722
|
-
|
|
723
|
-
const timeout = options.timeout ?? 30000;
|
|
1722
|
+
const hselect = async (selector, values, options) => {
|
|
724
1723
|
try {
|
|
725
|
-
const
|
|
726
|
-
if (
|
|
727
|
-
|
|
728
|
-
await sleep(rand(50, 150));
|
|
729
|
-
}
|
|
1724
|
+
const res = await selectHumanized(selector, values, options ?? {});
|
|
1725
|
+
if (res !== null)
|
|
1726
|
+
return res;
|
|
730
1727
|
}
|
|
731
|
-
catch {
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
if (got)
|
|
735
|
-
return got;
|
|
1728
|
+
catch (e) {
|
|
1729
|
+
if (isTimeout(e))
|
|
1730
|
+
throw e;
|
|
736
1731
|
}
|
|
737
|
-
catch { /* fall back */ }
|
|
738
1732
|
return nativePageSelect(selector, values, options);
|
|
739
1733
|
};
|
|
1734
|
+
page.selectOption = apiErrors("page.selectOption", (selector, values, options = {}) => locked(() => hselect(selector, values, options)));
|
|
1735
|
+
}
|
|
1736
|
+
// ------------------------------------------------------------------ check / uncheck / setChecked
|
|
1737
|
+
// A humanized click when the state must change, then the state verified. `trial` performs no action:
|
|
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
|
|
1740
|
+
// click owns its native fallback, so nothing here may click a second time -- and when one click did
|
|
1741
|
+
// not change the state (a page that cancels it), this raises like Playwright's check instead of
|
|
1742
|
+
// clicking again.
|
|
1743
|
+
const setChecked = async (orig, selector, checked, options) => {
|
|
1744
|
+
const opts = options ?? {};
|
|
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
|
+
}
|
|
1751
|
+
if ((await loc.isChecked({ timeout: opts.timeout })) === checked)
|
|
1752
|
+
return;
|
|
1753
|
+
await targetedClick(nativePageClick, selector, { ...opts }, false, null);
|
|
1754
|
+
if ((await loc.isChecked()) === checked)
|
|
1755
|
+
return;
|
|
1756
|
+
throw sdkPlaywrightError("Clicking the checkbox did not change its state");
|
|
1757
|
+
};
|
|
1758
|
+
for (const [name, want] of [["check", true], ["uncheck", false]]) {
|
|
1759
|
+
if (typeof page[name] === "function") {
|
|
1760
|
+
const orig = page[name].bind(page);
|
|
1761
|
+
page[name] = apiErrors("page." + name, (selector, options = {}) => locked(() => setChecked(orig, selector, want, options)));
|
|
1762
|
+
}
|
|
740
1763
|
}
|
|
741
|
-
|
|
742
|
-
const
|
|
743
|
-
page
|
|
744
|
-
|
|
745
|
-
|
|
1764
|
+
if (typeof page.setChecked === "function") {
|
|
1765
|
+
const origSc = page.setChecked.bind(page);
|
|
1766
|
+
page.setChecked = apiErrors("page.setChecked", (selector, checked, options = {}) => locked(() => setChecked((s, o) => origSc(s, checked, o), selector, Boolean(checked), options)));
|
|
1767
|
+
}
|
|
1768
|
+
// ------------------------------------------------------------------------- click / hover
|
|
1769
|
+
// The covered-point check, through the browser's own hit test read in the isolated world: nothing is
|
|
1770
|
+
// dispatched and the pointer does not move. It replaced a Playwright TRIAL action, which moves the
|
|
1771
|
+
// REAL pointer onto the element and back and performs a press with its events blocked: verifier B
|
|
1772
|
+
// saw, before every humanized click since 0.46.0, a mousemove inside the target, a 450-500 px jump
|
|
1773
|
+
// back, then the glide, and a pointerdown/pointerup/click (left, even for a right click) that window
|
|
1774
|
+
// capture listeners could see. A covered point is looked at again for COVER_CHECK_MS (an overlay
|
|
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) => {
|
|
1779
|
+
const deadline = Date.now() + COVER_CHECK_MS;
|
|
1780
|
+
let hit = await hitInBox(box, x, y, sel);
|
|
1781
|
+
while (hit === false && Date.now() < deadline) {
|
|
1782
|
+
await sleep(80);
|
|
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;
|
|
1810
|
+
}
|
|
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())));
|
|
1830
|
+
}
|
|
1831
|
+
};
|
|
1832
|
+
// The humanized form of page.click / dblclick / hover (and the click inside check/uncheck and the
|
|
1833
|
+
// drop-down opener).
|
|
1834
|
+
//
|
|
1835
|
+
// Every Playwright option is honoured: `button`, `clickCount`, `delay` (the hold) and `modifiers`
|
|
1836
|
+
// shape the press; `position` replaces the persona's point; `force` skips the covered-point check
|
|
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).
|
|
1840
|
+
const targetedClick = async (orig, selector, options, noClick, nativeOpts) => {
|
|
1841
|
+
const opts = options ?? {};
|
|
1842
|
+
const o = normalizeClickOptions(opts);
|
|
1843
|
+
let aim = null; // where Playwright's own click lands, once the box is known (native)
|
|
1844
|
+
const native = async () => {
|
|
1845
|
+
if (!orig)
|
|
1846
|
+
throw new Error("humanized click could not run");
|
|
746
1847
|
try {
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
return orig(selector, options);
|
|
1848
|
+
return await orig(selector, nativeOpts ?? opts);
|
|
1849
|
+
}
|
|
1850
|
+
finally {
|
|
1851
|
+
// Native click/hover genuinely move the browser pointer to the element. Leaving `placed`
|
|
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.
|
|
1855
|
+
st.placed = true;
|
|
1856
|
+
eng.at = null;
|
|
1857
|
+
if (aim) {
|
|
1858
|
+
st.x = aim.x;
|
|
1859
|
+
st.y = aim.y;
|
|
760
1860
|
}
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
1861
|
+
}
|
|
1862
|
+
};
|
|
1863
|
+
if (o.trial)
|
|
1864
|
+
return trial(selector, opts, noClick);
|
|
1865
|
+
try {
|
|
1866
|
+
const timeout = opts.timeout; // undefined: the page's default, as Playwright would use
|
|
1867
|
+
const loc = page.locator(selector).first();
|
|
1868
|
+
// Playwright skips actionability with force: no wait then -- a target it would click hidden goes
|
|
1869
|
+
// native at once instead of timing out first.
|
|
1870
|
+
const box = await boxOf(loc, timeout, o.force, selector);
|
|
1871
|
+
if (!box)
|
|
1872
|
+
throw new NativeSignal();
|
|
1873
|
+
aim = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
|
1874
|
+
if (!o.force && !(await loc.isEnabled()))
|
|
1875
|
+
throw new NativeSignal();
|
|
1876
|
+
let x, y;
|
|
1877
|
+
if (o.position) {
|
|
1878
|
+
({ x, y } = await positionPoint(box, o.position));
|
|
1879
|
+
aim = { x, y };
|
|
1880
|
+
}
|
|
1881
|
+
else {
|
|
1882
|
+
({ x, y } = clickPoint(box, st, persona));
|
|
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);
|
|
1889
|
+
if (!o.force && orig) {
|
|
1890
|
+
// Stability, then covered-by: 50 ms on, the element must still fill `box` (an animation in
|
|
1891
|
+
// flight does not) and own the click point -- no trusted press at a point some overlay owns.
|
|
1892
|
+
// Either failing goes native: Playwright waits for it.
|
|
1893
|
+
await sleep(50);
|
|
1894
|
+
if (!(await reachesPoint(box, x, y, selector)))
|
|
1895
|
+
throw new NativeSignal();
|
|
1896
|
+
}
|
|
1897
|
+
if (o.modifiers.length && !(nativeKbDown && nativeKbUp))
|
|
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
|
+
}
|
|
1905
|
+
if (noClick && o.modifiers.length) {
|
|
1906
|
+
// hover with modifiers: Playwright holds them across the move
|
|
1907
|
+
const held = [];
|
|
768
1908
|
try {
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
1909
|
+
for (const m of o.modifiers) {
|
|
1910
|
+
await nativeKbDown(m);
|
|
1911
|
+
held.push(m);
|
|
1912
|
+
await sleep(rand(40, 110));
|
|
1913
|
+
}
|
|
1914
|
+
await glide(x, y);
|
|
773
1915
|
}
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
1916
|
+
finally {
|
|
1917
|
+
for (const m of held.reverse()) {
|
|
1918
|
+
await sleep(rand(20, 80));
|
|
1919
|
+
try {
|
|
1920
|
+
await nativeKbUp(m);
|
|
1921
|
+
}
|
|
1922
|
+
catch { /* one failed keyup must not strand the others */ }
|
|
1923
|
+
}
|
|
778
1924
|
}
|
|
779
1925
|
return;
|
|
780
1926
|
}
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
1927
|
+
await glide(x, y);
|
|
1928
|
+
if (!noClick)
|
|
1929
|
+
await pressAt(o);
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1932
|
+
catch (e) {
|
|
1933
|
+
if (isTimeout(e))
|
|
1934
|
+
throw e;
|
|
1935
|
+
}
|
|
1936
|
+
// outside the try, so a native call that raises is not run a second time by the handler
|
|
1937
|
+
return native();
|
|
785
1938
|
};
|
|
786
|
-
|
|
787
|
-
|
|
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));
|
|
788
1943
|
// Marker the Locator-class patch keys off: humanize is active for THIS page.
|
|
789
1944
|
page._clearcoteHumanized = true;
|
|
790
1945
|
patchLocatorClass(page.locator("html"));
|
|
791
1946
|
}
|
|
792
|
-
/**
|
|
793
|
-
*
|
|
794
|
-
*
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
* cannot be resolved, an ElementHandle target, or a platform where arrows open the popup instead of
|
|
805
|
-
* stepping (macOS). selectedIndex is verified afterwards rather than assumed — a silently wrong
|
|
806
|
-
* selection is worse than an untrusted one. */
|
|
807
|
-
async function trustedSelect(page, selector, values, timeout) {
|
|
808
|
-
try {
|
|
809
|
-
const one = (v) => (Array.isArray(v) && v.length === 1 ? v[0] : v);
|
|
810
|
-
const v = one(values);
|
|
811
|
-
let by = null, want = null;
|
|
812
|
-
if (v && typeof v === "object" && !v.constructor?.name?.includes("ElementHandle")) {
|
|
813
|
-
if (v.index != null) {
|
|
814
|
-
by = "index";
|
|
815
|
-
want = v.index;
|
|
816
|
-
}
|
|
817
|
-
else if (v.label != null) {
|
|
818
|
-
by = "label";
|
|
819
|
-
want = v.label;
|
|
820
|
-
}
|
|
821
|
-
else if (v.value != null) {
|
|
822
|
-
by = "value";
|
|
823
|
-
want = v.value;
|
|
824
|
-
}
|
|
825
|
-
}
|
|
826
|
-
else if (typeof v === "string") {
|
|
827
|
-
by = "value";
|
|
828
|
-
want = v;
|
|
1947
|
+
/** @internal exported for tests. Playwright's selectOption values -> the wanted options, or null when
|
|
1948
|
+
* the humane route does not apply (nothing asked, an ElementHandle, an option object naming more than
|
|
1949
|
+
* one field): the caller then runs stock selectOption. */
|
|
1950
|
+
export function selectWants(values) {
|
|
1951
|
+
if (values === null || values === undefined)
|
|
1952
|
+
return null;
|
|
1953
|
+
const list = Array.isArray(values) ? values : [values];
|
|
1954
|
+
const out = [];
|
|
1955
|
+
for (const x of list) {
|
|
1956
|
+
if (typeof x === "string") {
|
|
1957
|
+
out.push({ kind: "value", v: x });
|
|
1958
|
+
continue;
|
|
829
1959
|
}
|
|
830
|
-
if (
|
|
1960
|
+
if (!x || typeof x !== "object")
|
|
831
1961
|
return null;
|
|
832
|
-
|
|
833
|
-
const plan = await worldFor(page).evaluate(ISO_SELECT_PLAN, { sel: selector, by, want });
|
|
834
|
-
if (!plan)
|
|
1962
|
+
if (typeof x.asElement === "function" || /Handle/.test(x.constructor?.name ?? ""))
|
|
835
1963
|
return null;
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
1964
|
+
const keys = ["value", "label", "index"].filter((k) => x[k] !== undefined && x[k] !== null);
|
|
1965
|
+
if (keys.length !== 1)
|
|
1966
|
+
return null;
|
|
1967
|
+
if (keys[0] === "index") {
|
|
1968
|
+
if (typeof x.index !== "number" || !Number.isInteger(x.index))
|
|
1969
|
+
return null;
|
|
1970
|
+
out.push({ kind: "index", v: x.index });
|
|
1971
|
+
}
|
|
1972
|
+
else if (keys[0] === "label") {
|
|
1973
|
+
out.push({ kind: "label", v: String(x.label) });
|
|
1974
|
+
}
|
|
1975
|
+
else {
|
|
1976
|
+
out.push({ kind: "valueOnly", v: String(x.value) });
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
return out.length ? out : null;
|
|
1980
|
+
}
|
|
1981
|
+
/** @internal exported for tests. The option index a want resolves to, Playwright's way, or -1. */
|
|
1982
|
+
export function wantIndex(opts, w) {
|
|
1983
|
+
switch (w.kind) {
|
|
1984
|
+
case "value": return matchOption(opts, { value: w.v });
|
|
1985
|
+
case "label": return matchOption(opts, { label: w.v });
|
|
1986
|
+
case "index": return matchOption(opts, { index: w.v });
|
|
1987
|
+
case "valueOnly": return opts.findIndex((o) => o.value === w.v);
|
|
849
1988
|
}
|
|
850
|
-
catch { /* fall back */ }
|
|
851
|
-
return null;
|
|
852
1989
|
}
|
|
853
1990
|
// --------------------------------------------------------------------------- locator patch
|
|
854
1991
|
let locatorPatched = false;
|
|
1992
|
+
// The options each page-level humanized method understands. Everything the caller passed that a page
|
|
1993
|
+
// method accepts travels with it: the old fwd sent ONLY `timeout`, which turned
|
|
1994
|
+
// click({button: "right"}) into a left click and click({trial: true}) into a real one.
|
|
1995
|
+
const CLICK_OPTS = ["button", "clickCount", "delay", "force", "modifiers", "noWaitAfter", "position", "timeout", "trial"];
|
|
1996
|
+
const HOVER_OPTS = ["force", "modifiers", "noWaitAfter", "position", "timeout", "trial"];
|
|
1997
|
+
const DBL_OPTS = ["button", "delay", "force", "modifiers", "noWaitAfter", "position", "timeout", "trial"];
|
|
1998
|
+
const FILL_OPTS = ["force", "noWaitAfter", "timeout"];
|
|
1999
|
+
const TYPE_OPTS = ["timeout"];
|
|
2000
|
+
const PRESS_OPTS = ["delay", "noWaitAfter", "timeout"];
|
|
2001
|
+
const SELECT_OPTS = ["force", "noWaitAfter", "timeout"];
|
|
2002
|
+
const CHECK_OPTS = ["force", "noWaitAfter", "position", "timeout", "trial"];
|
|
2003
|
+
/** @internal exported for tests: the subset of `kw` named in `names` (null/undefined dropped). */
|
|
2004
|
+
export function pickOptions(kw, names) {
|
|
2005
|
+
const out = {};
|
|
2006
|
+
if (kw && typeof kw === "object")
|
|
2007
|
+
for (const k of names)
|
|
2008
|
+
if (kw[k] !== undefined && kw[k] !== null)
|
|
2009
|
+
out[k] = kw[k];
|
|
2010
|
+
return out;
|
|
2011
|
+
}
|
|
855
2012
|
/** Patch the Locator prototype once so locator.* interactions route through the humanized page.*
|
|
856
2013
|
* methods. Each is a no-op unless the locator's page has humanize active AND the locator targets
|
|
857
|
-
* the main frame; anything else (frame locators,
|
|
858
|
-
*
|
|
2014
|
+
* the main frame; anything else (frame locators, another page) uses the original Playwright method.
|
|
2015
|
+
* Locator actions stay STRICT: more than one match goes to the native method, which raises
|
|
2016
|
+
* Playwright's own strict-mode error. Once routed, errors propagate as they are (the page method
|
|
2017
|
+
* already ran its own native fallback, so nothing is retried). Composes if another library already
|
|
2018
|
+
* patched the prototype. */
|
|
859
2019
|
function patchLocatorClass(sample) {
|
|
860
2020
|
if (locatorPatched)
|
|
861
2021
|
return;
|
|
@@ -863,136 +2023,139 @@ function patchLocatorClass(sample) {
|
|
|
863
2023
|
const proto = Object.getPrototypeOf(sample);
|
|
864
2024
|
if (!proto)
|
|
865
2025
|
return;
|
|
866
|
-
const oFill = proto.fill, oClick = proto.click, oType = proto.type, oDbl = proto.dblclick, oHover = proto.hover, oPress = proto.press, oPressSeq = proto.pressSequentially, oClear = proto.clear, oTap = proto.tap, oCheck = proto.check, oUncheck = proto.uncheck, oDragTo = proto.dragTo, oSelectOption = proto.selectOption;
|
|
2026
|
+
const oFill = proto.fill, oClick = proto.click, oType = proto.type, oDbl = proto.dblclick, oHover = proto.hover, oPress = proto.press, oPressSeq = proto.pressSequentially, oClear = proto.clear, oTap = proto.tap, oCheck = proto.check, oUncheck = proto.uncheck, oDragTo = proto.dragTo, oSelectOption = proto.selectOption, oSetChecked = proto.setChecked;
|
|
2027
|
+
// Main-frame locators only: a FrameLocator's child keeps the MAIN frame as its _frame and enters the
|
|
2028
|
+
// iframe through its selector, so the selector is what tells. Routed through page.fill, its
|
|
2029
|
+
// prefilled field was never cleared ("old" + "new" = "oldnew"); frame locators stay native.
|
|
867
2030
|
const on = (self) => {
|
|
868
2031
|
const pg = self.page();
|
|
869
2032
|
if (!pg || !pg._clearcoteHumanized)
|
|
870
2033
|
return false;
|
|
871
2034
|
try {
|
|
2035
|
+
if (String(self._selector ?? "").includes("internal:control=enter-frame"))
|
|
2036
|
+
return false;
|
|
872
2037
|
return self._frame === pg.mainFrame();
|
|
873
2038
|
}
|
|
874
2039
|
catch {
|
|
875
2040
|
return false;
|
|
876
2041
|
}
|
|
877
2042
|
};
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
}
|
|
885
|
-
catch { /* fall back */ }
|
|
886
|
-
}
|
|
887
|
-
return oFill.call(this, value, kw);
|
|
888
|
-
};
|
|
889
|
-
proto.click = async function (kw = {}) {
|
|
890
|
-
if (on(this)) {
|
|
891
|
-
try {
|
|
892
|
-
return await this.page().click(sel(this), fwd(kw));
|
|
893
|
-
}
|
|
894
|
-
catch { /* fall back */ }
|
|
895
|
-
}
|
|
896
|
-
return oClick.call(this, kw);
|
|
897
|
-
};
|
|
898
|
-
proto.type = async function (text, kw = {}) {
|
|
899
|
-
if (on(this)) {
|
|
900
|
-
try {
|
|
901
|
-
return await this.page().type(sel(this), text, fwd(kw));
|
|
902
|
-
}
|
|
903
|
-
catch { /* fall back */ }
|
|
904
|
-
}
|
|
905
|
-
return oType.call(this, text, kw);
|
|
906
|
-
};
|
|
907
|
-
proto.dblclick = async function (kw = {}) {
|
|
908
|
-
if (on(this)) {
|
|
909
|
-
try {
|
|
910
|
-
return await this.page().dblclick(sel(this), fwd(kw));
|
|
911
|
-
}
|
|
912
|
-
catch { /* fall back */ }
|
|
2043
|
+
// Locator actions are STRICT in Playwright: more than one match is an error. The page-level methods
|
|
2044
|
+
// they route through take the first match, so check the count here and let the native call raise
|
|
2045
|
+
// its own strict-mode error when it is not at most one.
|
|
2046
|
+
const strict = async (self) => {
|
|
2047
|
+
try {
|
|
2048
|
+
return (await self.count()) <= 1;
|
|
913
2049
|
}
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
proto.hover = async function (kw = {}) {
|
|
917
|
-
if (on(this)) {
|
|
918
|
-
try {
|
|
919
|
-
return await this.page().hover(sel(this), fwd(kw));
|
|
920
|
-
}
|
|
921
|
-
catch { /* fall back */ }
|
|
2050
|
+
catch {
|
|
2051
|
+
return false;
|
|
922
2052
|
}
|
|
923
|
-
return oHover.call(this, kw);
|
|
924
2053
|
};
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
return
|
|
2054
|
+
const sel = (self) => self._selector;
|
|
2055
|
+
// Once routed, every error propagates: the page-level wrappers own the native fallback, so retrying
|
|
2056
|
+
// the original locator method here ran the action a second (or third) time -- a checkbox whose click
|
|
2057
|
+
// is cancelled was clicked three times.
|
|
2058
|
+
const route = async (self, fn) => {
|
|
2059
|
+
if (on(self) && await strict(self))
|
|
2060
|
+
return [true, await fn()];
|
|
2061
|
+
return [false, undefined];
|
|
933
2062
|
};
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
catch { /* fall back */ }
|
|
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);
|
|
940
2068
|
}
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
proto.clear = async function (kw = {}) {
|
|
944
|
-
if (on(this)) {
|
|
945
|
-
try {
|
|
946
|
-
return await this.page().fill(sel(this), "", fwd(kw));
|
|
947
|
-
}
|
|
948
|
-
catch { /* fall back */ }
|
|
2069
|
+
catch (e) {
|
|
2070
|
+
throw asApiError(e, api);
|
|
949
2071
|
}
|
|
950
|
-
return oClear.call(this, kw);
|
|
951
2072
|
};
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
catch { /* fall back */ }
|
|
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;
|
|
958
2078
|
}
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
proto.check = async function (kw = {}) {
|
|
962
|
-
if (on(this)) {
|
|
963
|
-
try {
|
|
964
|
-
if (!(await this.isChecked()))
|
|
965
|
-
return await this.page().click(sel(this), fwd(kw));
|
|
966
|
-
return;
|
|
967
|
-
}
|
|
968
|
-
catch { /* fall back */ }
|
|
2079
|
+
catch {
|
|
2080
|
+
return false;
|
|
969
2081
|
}
|
|
970
|
-
return oCheck.call(this, kw);
|
|
971
2082
|
};
|
|
972
|
-
proto.
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
2083
|
+
proto.fill = apiMethod("locator.fill", async function (value, kw = {}) {
|
|
2084
|
+
const [done, r] = await route(this, () => this.page().fill(sel(this), value, pickOptions(kw, FILL_OPTS)));
|
|
2085
|
+
return done ? r : oFill.call(this, value, kw);
|
|
2086
|
+
});
|
|
2087
|
+
proto.click = apiMethod("locator.click", async function (kw = {}) {
|
|
2088
|
+
const [done, r] = await route(this, () => this.page().click(sel(this), pickOptions(kw, CLICK_OPTS)));
|
|
2089
|
+
return done ? r : oClick.call(this, kw);
|
|
2090
|
+
});
|
|
2091
|
+
proto.type = apiMethod("locator.type", async function (text, kw = {}) {
|
|
2092
|
+
const [done, r] = await route(this, () => this.page().type(sel(this), text, pickOptions(kw, TYPE_OPTS)));
|
|
2093
|
+
return done ? r : oType.call(this, text, kw);
|
|
2094
|
+
});
|
|
2095
|
+
proto.dblclick = apiMethod("locator.dblclick", async function (kw = {}) {
|
|
2096
|
+
const [done, r] = await route(this, () => this.page().dblclick(sel(this), pickOptions(kw, DBL_OPTS)));
|
|
2097
|
+
return done ? r : oDbl.call(this, kw);
|
|
2098
|
+
});
|
|
2099
|
+
proto.hover = apiMethod("locator.hover", async function (kw = {}) {
|
|
2100
|
+
const [done, r] = await route(this, () => this.page().hover(sel(this), pickOptions(kw, HOVER_OPTS)));
|
|
2101
|
+
return done ? r : oHover.call(this, kw);
|
|
2102
|
+
});
|
|
2103
|
+
proto.press = apiMethod("locator.press", async function (key, kw = {}) {
|
|
2104
|
+
const [done, r] = await route(this, () => this.page().press(sel(this), key, pickOptions(kw, PRESS_OPTS)));
|
|
2105
|
+
return done ? r : oPress.call(this, key, kw);
|
|
2106
|
+
});
|
|
2107
|
+
proto.pressSequentially = apiMethod("locator.pressSequentially", async function (text, kw = {}) {
|
|
2108
|
+
const [done, r] = await route(this, () => this.page().type(sel(this), text, pickOptions(kw, TYPE_OPTS)));
|
|
2109
|
+
return done ? r : oPressSeq.call(this, text, kw);
|
|
2110
|
+
});
|
|
2111
|
+
proto.clear = apiMethod("locator.clear", async function (kw = {}) {
|
|
2112
|
+
const [done, r] = await route(this, () => this.page().fill(sel(this), "", pickOptions(kw, FILL_OPTS)));
|
|
2113
|
+
return done ? r : oClear.call(this, kw);
|
|
2114
|
+
});
|
|
2115
|
+
proto.tap = apiMethod("locator.tap", async function (kw = {}) {
|
|
2116
|
+
if (noTouch(this))
|
|
2117
|
+
return oTap.call(this, kw);
|
|
2118
|
+
const [done, r] = await route(this, () => this.page().click(sel(this), pickOptions(kw, HOVER_OPTS)));
|
|
2119
|
+
return done ? r : oTap.call(this, kw);
|
|
2120
|
+
});
|
|
2121
|
+
proto.check = apiMethod("locator.check", async function (kw = {}) {
|
|
2122
|
+
const [done, r] = await route(this, () => this.page().check(sel(this), pickOptions(kw, CHECK_OPTS)));
|
|
2123
|
+
return done ? r : oCheck.call(this, kw);
|
|
2124
|
+
});
|
|
2125
|
+
proto.uncheck = apiMethod("locator.uncheck", async function (kw = {}) {
|
|
2126
|
+
const [done, r] = await route(this, () => this.page().uncheck(sel(this), pickOptions(kw, CHECK_OPTS)));
|
|
2127
|
+
return done ? r : oUncheck.call(this, kw);
|
|
2128
|
+
});
|
|
2129
|
+
if (typeof oSetChecked === "function") {
|
|
2130
|
+
proto.setChecked = apiMethod("locator.setChecked", async function (checked, kw = {}) {
|
|
2131
|
+
const [done, r] = await route(this, () => this.page().setChecked(sel(this), checked, pickOptions(kw, CHECK_OPTS)));
|
|
2132
|
+
return done ? r : oSetChecked.call(this, checked, kw);
|
|
2133
|
+
});
|
|
2134
|
+
}
|
|
2135
|
+
// Delegate to the humanized page.selectOption, like every other method here: one implementation
|
|
2136
|
+
// behind the page method is what stops the two entry points drifting apart.
|
|
2137
|
+
if (typeof oSelectOption === "function") {
|
|
2138
|
+
proto.selectOption = apiMethod("locator.selectOption", async function (values, kw = {}) {
|
|
2139
|
+
const [done, r] = await route(this, () => this.page().selectOption(sel(this), values, pickOptions(kw, SELECT_OPTS)));
|
|
2140
|
+
return done ? r : oSelectOption.call(this, values, kw);
|
|
2141
|
+
});
|
|
2142
|
+
}
|
|
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
|
+
}
|
|
977
2155
|
return;
|
|
978
2156
|
}
|
|
979
|
-
catch { /* fall back */ }
|
|
980
2157
|
}
|
|
981
|
-
|
|
982
|
-
};
|
|
983
|
-
proto.selectOption = async function (values, kw = {}) {
|
|
984
|
-
// Routed through the page method (which glides to the control first) rather than calling
|
|
985
|
-
// trustedSelect straight, so a locator selection gets the same pre-move as a locator click.
|
|
986
|
-
if (on(this)) {
|
|
987
|
-
try {
|
|
988
|
-
return await this.page().selectOption(sel(this), values, fwd(kw));
|
|
989
|
-
}
|
|
990
|
-
catch { /* fall back */ }
|
|
991
|
-
}
|
|
992
|
-
return oSelectOption.call(this, values, kw);
|
|
993
|
-
};
|
|
994
|
-
proto.dragTo = async function (target, kw = {}) {
|
|
995
|
-
if (on(this)) {
|
|
2158
|
+
if (on(this) && !kw?.trial) {
|
|
996
2159
|
try {
|
|
997
2160
|
const pg = this.page();
|
|
998
2161
|
const sb = await this.boundingBox();
|
|
@@ -1005,9 +2168,8 @@ function patchLocatorClass(sample) {
|
|
|
1005
2168
|
const persona = pg._clearcotePersona;
|
|
1006
2169
|
const { grabMs, releaseMs } = persona ? dragDwell(persona) : { grabMs: rand(130, 360), releaseMs: rand(90, 230) };
|
|
1007
2170
|
await pg.mouse.move(sx, sy);
|
|
1008
|
-
await sleep(rand(100, 200));
|
|
1009
2171
|
await pg.mouse.down();
|
|
1010
|
-
await sleep(grabMs); //
|
|
2172
|
+
await sleep(grabMs); // pre-press pause inside down(); then the grab hesitation
|
|
1011
2173
|
const heldGlide = pg._clearcoteHeldGlide; // humanized held-button drag + seating jiggle (settle)
|
|
1012
2174
|
if (heldGlide)
|
|
1013
2175
|
await heldGlide(tx, ty);
|
|
@@ -1021,7 +2183,7 @@ function patchLocatorClass(sample) {
|
|
|
1021
2183
|
catch { /* fall back */ }
|
|
1022
2184
|
}
|
|
1023
2185
|
return oDragTo.call(this, target, kw);
|
|
1024
|
-
};
|
|
2186
|
+
});
|
|
1025
2187
|
}
|
|
1026
2188
|
/** Install humanize/showCursor on a browser: wraps newPage/newContext so every page is covered. */
|
|
1027
2189
|
export function installHumanize(browser, opts) {
|