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/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
- // page.mouse.move / click / wheel and held-button drags (down -> move -> up),
9
- // page.keyboard.type / press
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 / tap / selectOption / dragTo
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 { COVER_CHECK_MS, ISO_IS_FOCUSED, ISO_PLATFORM, ISO_SELECT_PLAN, ISO_SELECTED_INDEX, ISO_VIEWPORT, worldFor } from "./isolated.js";
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
- // the old name was listed in /json/protocol for anyone who asked, and "humanizedClick" is known
51
- // vocabulary, so the method list alone said what the build was for. r33 and older only know the old
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. Any
64
- * error other than method-not-found propagates as-is: retrying a real failure under the old name
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
- const r = await session.send(legacy, params);
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
- // Compact keyboard-adjacency map for realistic fat-finger typos.
99
- const NEARBY = {
100
- a: "sqwz", b: "vghn", c: "xdfv", d: "sfecx", e: "wrsdf", f: "dgrtcv",
101
- g: "fhtyb", h: "gjybn", i: "ujko", j: "hkunm", k: "jloi", l: "kop",
102
- m: "njk", n: "bhjm", o: "iklp", p: "ol", q: "wa", r: "edft",
103
- s: "awedxz", t: "rfgy", u: "yhji", v: "cfgb", w: "qase", x: "zsdc",
104
- y: "tghu", z: "asx",
105
- };
106
- function nearbyKey(ch) {
107
- const lo = ch.toLowerCase();
108
- const n = NEARBY[lo];
109
- if (!n)
110
- return ch;
111
- const w = n[Math.floor(Math.random() * n.length)];
112
- return ch === ch.toUpperCase() && ch !== ch.toLowerCase() ? w.toUpperCase() : w;
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
- for (const s of steps) {
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 nativeMove(s.x, s.y);
167
- }
168
- catch {
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
- st.placed = true;
172
- await sleep(s.sleepMs);
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
- await sleep(dur * 1000);
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
- const steps = planAmbient(st, await viewport(), persona, ms);
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
- await glide(x, y);
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
- mouse.click = async (x, y) => {
341
- if (await engineGlide(x, y, false))
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
- await nativeClick(x, y, { delay: clickHold(persona) });
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 alnum chars, then notice + correct
845
+ // occasional fat-finger on unshifted letters and digits, then notice + correct
497
846
  let typo = false;
498
- if (!need && /[a-z0-9]/.test(ch) && Math.random() < 0.02) {
499
- typo = true;
500
- try {
501
- await emitKey(nearbyKey(ch));
502
- await sleep(rand(120, 300));
503
- await nativeKbPress("Backspace", { delay: keyDwell(persona) });
504
- await sleep(rand(80, 200));
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(rand(45, 150));
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 (text) => { await keyboardWarmup(); await humanTypeText(text); };
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
- keyboard.press = async (key, options = {}) => {
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 === undefined)
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
- // Read in an isolated world (./isolated.ts): the page never sees the lookup.
614
- const isFocused = async (selector) => Boolean(await worldFor(page).evaluate(ISO_IS_FOCUSED, selector));
615
- const pointFor = async (selector, timeout) => {
616
- const loc = page.locator(selector).first();
617
- await loc.waitFor({ state: "visible", timeout });
618
- await loc.scrollIntoViewIfNeeded({ timeout });
619
- if (!(await loc.isEnabled()))
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
- let box = await loc.boundingBox();
622
- if (!box)
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
- await sleep(50); // stability: a box that's still moving = an animation in flight
625
- const box2 = await loc.boundingBox();
626
- if (!box2 || Math.abs(box2.x - box.x) > 1 || Math.abs(box2.y - box.y) > 1)
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 = box2;
629
- return clickPoint(box, st, persona); // 2D gaussian toward center, nudged to the approach side
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
- const focusClick = async (selector, timeout) => {
632
- const pt = await pointFor(selector, timeout);
633
- if (!pt)
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 sleep(rand(40, 130));
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 nativePageSelect = page.selectOption ? page.selectOption.bind(page) : null;
645
- page.type = async (selector, text, options = {}) => {
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
- const timeout = options.timeout ?? 30000;
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
- return nativePageType ? nativePageType(selector, text, options) : nativePageFill(selector, text, options);
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.fill = async (selector, value, options = {}) => {
661
- try {
662
- const timeout = options.timeout ?? 30000;
663
- if (value.length > 200)
664
- return nativePageFill(selector, value, options); // bulk -> atomic
665
- if (!(await focusClick(selector, timeout)))
666
- return nativePageFill(selector, value, options);
667
- await sleep(rand(40, 120));
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 nativeKbPress(SELECT_ALL);
670
- await sleep(rand(30, 80));
671
- await nativeKbPress("Backspace");
672
- await sleep(rand(40, 120));
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
- catch { /* clear best-effort */ }
675
- await humanTypeText(value);
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
- catch {
678
- return nativePageFill(selector, value, options);
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
- page.dblclick = async (selector, options = {}) => {
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
- const timeout = options.timeout ?? 30000;
684
- const pt = await pointFor(selector, timeout);
685
- if (!pt)
686
- return nativePageDblclick(selector, options);
687
- await glide(pt.x, pt.y);
688
- await sleep(rand(40, 130));
689
- await nativeClick(pt.x, pt.y, { clickCount: 2, delay: rand(40, 90) });
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
- return nativePageDblclick(selector, options);
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
- page.press = async (selector, key, options = {}) => {
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
- const timeout = options.timeout ?? 30000;
698
- if (!(await isFocused(selector))) {
699
- if (!(await focusClick(selector, timeout)))
700
- return nativePagePress(selector, key, options);
701
- await sleep(rand(40, 120));
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
- catch {
709
- return nativePagePress(selector, key, options);
1557
+ finally {
1558
+ await sleep(rand(20, 60));
1559
+ await nativeKbUp(k); // never leave the key down
710
1560
  }
711
1561
  };
712
- // page.selectOption was the last input method humanize did not touch, so a script that used it —
713
- // rather than locator.selectOption, which has been patched for a while — went straight to
714
- // Playwright's implementation: assign the value, dispatch input+change from page script. That is
715
- // measurably scored: it fails 'interaction-select-change-trust' on clearcotelabs.com/audit,
716
- // because no user gesture can produce a change event with isTrusted false.
717
- //
718
- // The move is a HOVER, not a click: clicking a <select> opens the native popup, and the popup
719
- // swallows the arrow keys the trusted path steps the selection with. A person's pointer is on the
720
- // dropdown they are choosing from either way.
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
- page.selectOption = async (selector, values, options = {}) => {
723
- const timeout = options.timeout ?? 30000;
1722
+ const hselect = async (selector, values, options) => {
724
1723
  try {
725
- const pt = await pointFor(selector, timeout);
726
- if (pt) {
727
- await glide(pt.x, pt.y);
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 { /* the pre-move is best-effort; a trusted selection still beats a native one */ }
732
- try {
733
- const got = await trustedSelect(page, selector, values, timeout);
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
- const wrapTargeted = (name, noClick) => {
742
- const orig = page[name].bind(page);
743
- page[name] = async (selector, options = {}) => {
744
- // Actionability pre-flight before a TRUSTED humanized click: visible+enabled+stable+
745
- // not-covered, else fall back to the native click (which has its own waits) — only improves.
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
- const timeout = options.timeout ?? 30000;
748
- const loc = page.locator(selector).first();
749
- await loc.waitFor({ state: "visible", timeout });
750
- await loc.scrollIntoViewIfNeeded({ timeout });
751
- if (!(await loc.isEnabled()))
752
- return orig(selector, options);
753
- let box = await loc.boundingBox();
754
- if (!box)
755
- return orig(selector, options);
756
- await sleep(50);
757
- const box2 = await loc.boundingBox();
758
- if (!box2 || Math.abs(box2.x - box.x) > 1 || Math.abs(box2.y - box.y) > 1) {
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
- box = box2;
762
- const cp = clickPoint(box, st, persona);
763
- const x = cp.x, y = cp.y;
764
- // covered-by: don't fire a trusted click at a point some overlay owns. A TRIAL action runs
765
- // Playwright's own hit test at exactly this point, in its utility world, and dispatches
766
- // nothing; the old page.evaluate(elementFromPoint) ran in the page's world, where a hooked
767
- // prototype saw it. A covered point times out fast -> the native path waits for it.
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
- await orig(selector, { trial: true, timeout: COVER_CHECK_MS, position: { x: x - box.x, y: y - box.y } });
770
- }
771
- catch {
772
- return orig(selector, options); // covered -> let PW wait for it on top
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
- await glide(x, y);
775
- if (!noClick) {
776
- await sleep(rand(40, 130));
777
- await nativeClick(x, y, { delay: clickHold(persona) });
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
- catch {
782
- return orig(selector, options);
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
- wrapTargeted("click", false);
787
- wrapTargeted("hover", true);
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
- /** Choose a <select> option with the keyboard, so the ENGINE fires the events. Returns Playwright's
793
- * `[value]` result once the selection is VERIFIED, or null when the keyboard route cannot be shown
794
- * to have worked and the caller should fall back to native.
795
- *
796
- * Playwright's selectOption assigns the value and dispatches input+change from script. Those arrive
797
- * with isTrusted false, which is the single most reliable dropdown tell there is — the engine cannot
798
- * produce an untrusted change, so a page reading the flag knows the selection was not made by a
799
- * person. A <select> that has focus and is CLOSED steps on ArrowUp/ArrowDown and the browser emits
800
- * input then change itself, trusted, exactly as it does for a mouse. The fix is not to forge better
801
- * events, it is to stop forging them.
802
- *
803
- * Returns null whenever that cannot be guaranteed: a multi- or disabled select, an option that
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 (by === null)
1960
+ if (!x || typeof x !== "object")
831
1961
  return null;
832
- // Read in an isolated world (./isolated.ts): the page never sees the option lookup.
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
- if (plan.to === plan.from)
837
- return [plan.ret]; // already selected; forge nothing
838
- await page.focus(selector, { timeout });
839
- await sleep(rand(60, 160));
840
- const step = plan.to > plan.from ? "ArrowDown" : "ArrowUp";
841
- for (let i = 0; i < Math.abs(plan.to - plan.from); i++) {
842
- // page.keyboard.press — humanized by attachHumanize, so the hold comes with it.
843
- await page.keyboard.press(step);
844
- await sleep(rand(45, 120));
845
- }
846
- const got = await worldFor(page).evaluate(ISO_SELECTED_INDEX, selector);
847
- if (got === plan.to)
848
- return [plan.ret];
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, exotic selectors, errors) falls through to the
858
- * original Playwright behaviour. Composes if another library already patched the prototype. */
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
- const sel = (self) => self._selector;
879
- const fwd = (kw) => (kw && kw.timeout != null ? { timeout: kw.timeout } : {});
880
- proto.fill = async function (value, kw = {}) {
881
- if (on(this)) {
882
- try {
883
- return await this.page().fill(sel(this), value, fwd(kw));
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
- return oDbl.call(this, kw);
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
- proto.press = async function (key, kw = {}) {
926
- if (on(this)) {
927
- try {
928
- return await this.page().press(sel(this), key, fwd(kw));
929
- }
930
- catch { /* fall back */ }
931
- }
932
- return oPress.call(this, key, kw);
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
- proto.pressSequentially = async function (text, kw = {}) {
935
- if (on(this)) {
936
- try {
937
- return await this.page().type(sel(this), text, fwd(kw));
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
- return oPressSeq.call(this, text, kw);
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
- proto.tap = async function (kw = {}) {
953
- if (on(this)) {
954
- try {
955
- return await this.page().click(sel(this), fwd(kw));
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
- return oTap.call(this, kw);
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.uncheck = async function (kw = {}) {
973
- if (on(this)) {
974
- try {
975
- if (await this.isChecked())
976
- return await this.page().click(sel(this), fwd(kw));
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
- return oUncheck.call(this, kw);
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); // grab hesitation (native -> button held across glide)
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) {