clearcote 0.48.0 → 0.49.0

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