clearcote 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -128,7 +128,7 @@ Geo data comes from the offline [geoip-all-in-one](https://github.com/daijro/geo
128
128
 
129
129
  ### Humanized input (`humanize`, `showCursor`)
130
130
 
131
- `humanize: true` installs **one consistent human-input standard** across every Playwright surface (`page.click`/`hover`/`dblclick`/`type`/`fill`/`press`, `page.mouse.*`, `page.keyboard.type`, and the `Locator` equivalents incl. `dragTo`), dispatched as **native trusted input** (`isTrusted === true`, `navigator.webdriver` stays `false`). Mouse paths are slightly bowed cubic-beziers from the *last* cursor position (no snap to the corner), walked as a **sum of sub-movements with a min-jerk velocity profile** (a ballistic primary + corrective move — multi-peak velocity, not one symmetric bell); a held `mouse.down()` stays pressed across the move so `down → move → up` is a real drag. Typing is key-by-key with **gaussian inter-key timing** + word-boundary pauses + the occasional fat-finger correction (bulk values over 200 chars stay atomic). Scrolling uses **ease-out inertia** with the occasional reading pause. `showCursor: true` injects a cursor dot for headed runs. Both default off.
131
+ `humanize: true` installs **one consistent human-input standard** across every Playwright surface (`page.click`/`hover`/`dblclick`/`type`/`fill`/`press`/`selectOption`, `page.mouse.*`, `page.keyboard.type`/`press`, and the `Locator` equivalents incl. `dragTo`), dispatched as **native trusted input** (`isTrusted === true`, `navigator.webdriver` stays `false`). Mouse paths are slightly bowed cubic-beziers from the *last* cursor position (no snap to the corner), walked as a **sum of sub-movements with a min-jerk velocity profile** (a ballistic primary + corrective move — multi-peak velocity, not one symmetric bell); a held `mouse.down()` stays pressed across the move so `down → move → up` is a real drag. Typing is key-by-key with **gaussian inter-key timing** + word-boundary pauses + the occasional fat-finger correction (bulk values over 200 chars stay atomic). Scrolling uses **ease-out inertia** with the occasional reading pause, and moves the pointer over the content first when it isn't already there (a wheel event carries the cursor position). `<select>` options are chosen by stepping the focused, closed dropdown with the arrow keys, so the browser — not a script — emits the `change` event. `showCursor: true` injects a cursor dot for headed runs. Both default off.
132
132
 
133
133
  ### Render-backend coherence check (`checkRenderCoherence`)
134
134
 
@@ -1 +1 @@
1
- {"version":3,"file":"humanize.d.ts","sourceRoot":"","sources":["../src/humanize.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,OAAO,EAAE,cAAc,EAAW,IAAI,EAAE,MAAM,iBAAiB,CAAC;AAG9E,MAAM,WAAW,eAAe;IAC9B,iFAAiF;IACjF,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,oEAAoE;IACpE,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;qGACiG;IACjG,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CACxB;AAED;sEACsE;AACtE,eAAO,MAAM,cAAc,o5BAerB,CAAC;AAuBP,mFAAmF;AACnF,wBAAsB,cAAc,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC,CA8UvG;AAmKD,mGAAmG;AACnG,wBAAgB,eAAe,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,eAAe,GAAG,IAAI,CAc7E;AAED,0FAA0F;AAC1F,wBAAgB,wBAAwB,CACtC,OAAO,EAAE,cAAc,EAAE,IAAI,EAAE,eAAe,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,IAAI,GACvE,IAAI,CAQN"}
1
+ {"version":3,"file":"humanize.d.ts","sourceRoot":"","sources":["../src/humanize.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAE,OAAO,EAAE,cAAc,EAAW,IAAI,EAAE,MAAM,iBAAiB,CAAC;AAG9E,MAAM,WAAW,eAAe;IAC9B,iFAAiF;IACjF,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,oEAAoE;IACpE,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;qGACiG;IACjG,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CACxB;AAED;sEACsE;AACtE,eAAO,MAAM,cAAc,o5BAerB,CAAC;AA2BP,mFAAmF;AACnF,wBAAsB,cAAc,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC,CA4fvG;AAwKD,mGAAmG;AACnG,wBAAgB,eAAe,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,eAAe,GAAG,IAAI,CAc7E;AAED,0FAA0F;AAC1F,wBAAgB,wBAAwB,CACtC,OAAO,EAAE,cAAc,EAAE,IAAI,EAAE,eAAe,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,IAAI,GACvE,IAAI,CAQN"}
package/dist/humanize.js CHANGED
@@ -4,11 +4,13 @@
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, page.mouse.move /
8
- // click / wheel and held-button drags (down -> move -> up), page.keyboard.type
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
9
10
  // locator level — locator.click / type / fill / hover / dblclick / press /
10
- // pressSequentially / clear / check / uncheck / tap / dragTo (routed through
11
- // the humanized page methods; main-frame locators only, native fallback)
11
+ // pressSequentially / clear / check / uncheck / tap / selectOption / dragTo
12
+ // (routed through the humanized page methods; main-frame locators only,
13
+ // native fallback)
12
14
  // `showCursor: true` — injects a red cursor dot that follows the real mousemove events.
13
15
  //
14
16
  // Mouse moves build an eased, slightly bowed cubic-bezier path SDK-side from the tracked cursor
@@ -17,7 +19,7 @@
17
19
  // (slider captchas work) with no separate CDP channel to desync. Typing goes key-by-key through
18
20
  // the native keyboard (trusted; shift handled by the engine) with human inter-key timing. No
19
21
  // special binary command required.
20
- import { makePersona, planMove, dragDwell, clickHold, keyDwell, clickPoint, planAmbient } from "./motion.js";
22
+ import { makePersona, planMove, dragDwell, clickHold, keyDwell, clickPoint, planAmbient, gaussFrom } from "./motion.js";
21
23
  /** Overlay: a red dot that follows real mousemove events. Idempotent IIFE so it works both as
22
24
  * an addInitScript source string AND when passed to page.evaluate. */
23
25
  export const CURSOR_OVERLAY = `(() => {
@@ -37,6 +39,10 @@ export const CURSOR_OVERLAY = `(() => {
37
39
  }, true);
38
40
  })();`;
39
41
  const rand = (a, b) => a + Math.random() * (b - a);
42
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
43
+ // Same unseeded live source as rand() — the seeded persona rng stays inside motion.ts, this is only
44
+ // for placing a point in a plausible spread instead of on an exact landmark.
45
+ const gauss = () => gaussFrom(Math.random);
40
46
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
41
47
  // Compact keyboard-adjacency map for realistic fat-finger typos.
42
48
  const NEARBY = {
@@ -88,11 +94,18 @@ export async function attachHumanize(browser, page, opts) {
88
94
  const persona = makePersona(opts.seed);
89
95
  page._clearcotePersona = persona;
90
96
  // Tracked cursor position so each move continues from where the last one ended.
91
- const st = { x: rand(140, 380), y: rand(90, 240) };
97
+ // `held` tracks whether a mouse button is currently down — see mouse.move below.
98
+ // `placed` records whether the engine's cursor has actually been moved yet. A page starts with the
99
+ // pointer at the default origin, so anything that delivers input WITHOUT a target to move to —
100
+ // a wheel, a mouse.down that opens a drag, a bare keyboard.type — otherwise happens from a cursor
101
+ // that has never been anywhere. The guarantees below key off this flag.
102
+ const st = { x: rand(140, 380), y: rand(90, 240), held: false, placed: false };
92
103
  const mouse = page.mouse;
93
104
  const nativeMove = mouse.move.bind(mouse);
94
105
  const nativeClick = mouse.click.bind(mouse);
95
106
  const nativeWheel = mouse.wheel.bind(mouse);
107
+ const nativeDown = mouse.down.bind(mouse);
108
+ const nativeUp = mouse.up.bind(mouse);
96
109
  // Walk a planned trajectory (motion.ts) with native (trusted) mouse.move + off-protocol sleeps.
97
110
  // Native input carries the button state, so a button pressed via mouse.down() stays held across
98
111
  // the whole path — the same code powers a free move AND a held-button drag.
@@ -104,6 +117,7 @@ export async function attachHumanize(browser, page, opts) {
104
117
  catch {
105
118
  break;
106
119
  }
120
+ st.placed = true;
107
121
  await sleep(s.sleepMs);
108
122
  }
109
123
  };
@@ -135,12 +149,19 @@ export async function attachHumanize(browser, page, opts) {
135
149
  const dist = Math.sqrt(dx * dx + dy * dy);
136
150
  const dur = Math.min(1.10, Math.max(0.28, 0.30 + dist / 1700)) * (0.85 + 0.30 * Math.random());
137
151
  await eng.session.send("Browser.humanizedClick", { targetId: eng.tid, x, y, duration: dur, noClick });
152
+ // The engine walks the trajectory in the BROWSER process and answers this command before it
153
+ // has finished. Without waiting, a following mouse.down() fires at the cursor's STALE
154
+ // position — measured: a move to (110,58) then a press landed at (981,629), on <body>
155
+ // instead of the target, so every move-then-press sequence (drag, press-and-hold,
156
+ // drag-and-drop) acted on the wrong element.
157
+ await sleep(dur * 1000);
138
158
  try {
139
159
  await nativeMove(x, y);
140
160
  }
141
161
  catch { /* keep playwright cursor pos in sync for drags */ }
142
162
  st.x = x;
143
163
  st.y = y;
164
+ st.placed = true;
144
165
  return true;
145
166
  }
146
167
  catch {
@@ -148,8 +169,121 @@ export async function attachHumanize(browser, page, opts) {
148
169
  return false;
149
170
  }
150
171
  };
151
- mouse.move = async (x, y) => { if (!(await engineGlide(x, y, true)))
152
- await glide(x, y); };
172
+ // The REAL viewport, in CSS px — NOT page.viewportSize().
173
+ //
174
+ // installHeadedViewport (index.ts) forces `viewport: null` on every headed newPage/newContext,
175
+ // and launchPersistentContext does the same, so that innerWidth tracks the real OS window
176
+ // instead of Playwright's emulated 1280x720 (an emulated viewport on a headed window is itself
177
+ // a tell). With viewport:null, viewportSize() returns null for the LIFE of the page — so the
178
+ // `|| {1280, 800}` fallback was taken on every headed launch, and the scroll anchor's
179
+ // "is the pointer inside the viewport" gate was evaluated against a box unrelated to the
180
+ // window. On a maximized display a cursor legitimately at x=1400 read as out-of-bounds and got
181
+ // re-homed on EVERY scroll — precisely the behaviour that gate exists to prevent.
182
+ // innerWidth/innerHeight are correct in both modes; the constant is a last resort only.
183
+ let vpCache = null;
184
+ const viewport = async () => {
185
+ try {
186
+ const wh = (await page.evaluate("() => [innerWidth, innerHeight]"));
187
+ if (wh && wh[0] && wh[1]) {
188
+ vpCache = { width: wh[0], height: wh[1] };
189
+ return vpCache;
190
+ }
191
+ }
192
+ catch { /* detached/navigating — fall through */ }
193
+ return (page.viewportSize && page.viewportSize()) || vpCache || { width: 1280, height: 800 };
194
+ };
195
+ // Non-goal cursor activity (idle drift + a few excursions). It never presses a button and never
196
+ // approaches a caller-named target, so it is also the safe way to give the cursor a position when
197
+ // an action needs one but names no coordinates.
198
+ const ambient = async (ms) => {
199
+ try {
200
+ const steps = planAmbient(st, await viewport(), persona, ms);
201
+ await dispatch(steps);
202
+ if (steps.length) {
203
+ st.x = steps[steps.length - 1].x;
204
+ st.y = steps[steps.length - 1].y;
205
+ }
206
+ }
207
+ catch { /* best-effort ambient */ }
208
+ };
209
+ // Give the cursor a position for input that names no coordinates (a bare keyboard.type). It is
210
+ // ambient only: it never presses, so it cannot activate anything it happens to pass over.
211
+ //
212
+ // NOT used before mouse.down — see that wrapper. Before any real move st.x/st.y is the RANDOM
213
+ // SEED assigned at attach, so pressing at the end of this walk would put a TRUSTED click at an
214
+ // arbitrary page coordinate the caller never named, which can land on a link and navigate.
215
+ const ensurePlaced = async () => {
216
+ if (st.placed)
217
+ return;
218
+ await ambient(rand(260, 560));
219
+ if (!st.placed) {
220
+ try {
221
+ await nativeMove(st.x, st.y);
222
+ st.placed = true;
223
+ }
224
+ catch { /* best-effort placement */ }
225
+ }
226
+ };
227
+ // A HELD BUTTON MUST STAY ON THE NATIVE PATH. The engine trajectory builds its own
228
+ // WebMouseEvents and cannot hold a button across them, so routing a drag leg through it emits
229
+ // moves reporting MouseEvent.buttons 0 in the middle of a press. That is both a contradiction no
230
+ // real mouse produces (buttons is derived from physical state) and a functional break — a range
231
+ // thumb, a map or a drop target ignores a move that says nothing is down. Measured before this
232
+ // guard: 672 of 829 held moves reported buttons 0, and a slider dragged 10%->90% never left 0.
233
+ // A BARE MOVE ALWAYS USES THE NATIVE PATH — never the engine trajectory. Two independent
234
+ // reasons, both measured on the Python sync file which shares this design:
235
+ // * a held button cannot be carried across the engine's own WebMouseEvents, so a drag leg
236
+ // routed through it reports MouseEvent.buttons 0 mid-press and a range thumb ignores it
237
+ // (349 of 354 held moves reported buttons 0; the slider moved 0 -> 1 and stopped);
238
+ // * the engine walks its path ASYNCHRONOUSLY in the browser process and answers before it has
239
+ // finished, so it keeps moving the cursor AFTER the pre-press pin in mouse.down — a move to
240
+ // (56,120) then down() pressed on HTML at (856,213) instead of the INPUT, and the slider
241
+ // stayed at 0. Re-pinning right before the press does not help; the overrun outlives it.
242
+ // mouse.click keeps engine routing: it performs move+click entirely engine-side, where nothing
243
+ // races the trajectory. glide() is still fully humanized (minimum-jerk, colored noise, Fitts).
244
+ mouse.move = async (x, y) => {
245
+ await glide(x, y);
246
+ };
247
+ // down/up are wrapped to TRACK the held state mouse.move needs, and — critically — to make the
248
+ // press position DETERMINISTIC. The engine answers humanizedClick before its trajectory has
249
+ // finished walking, and it overruns the `duration` it reports, so sleeping that duration is not
250
+ // enough: measured on Node, a move to (110,58) was still mid-flight at (416,504) when the press
251
+ // fired, landing on <body>. Re-issuing a native move to the tracked target immediately before
252
+ // the press pins the cursor to where the caller asked, whatever the trajectory is doing.
253
+ mouse.down = async (...a) => {
254
+ // DELIBERATELY NO PLACEMENT WHEN THE POINTER HAS NEVER MOVED.
255
+ //
256
+ // An earlier revision called ensurePlaced() here and pressed wherever the ambient walk ended.
257
+ // But before any real move st.x/st.y is the RANDOM SEED assigned at attach — not a coordinate
258
+ // the caller named — so that converted a harmless no-op press at the origin into a TRUSTED
259
+ // click at an arbitrary point on the page, which can hit a link and navigate. That is a worse
260
+ // failure than the one it was meant to fix. A bare down() with no preceding move is a caller
261
+ // mistake, and humanize must not invent a target to paper over it; callers who want a
262
+ // humanized drag move first, and that move glides and sets `placed`.
263
+ if (st.placed) {
264
+ // Re-pin only once a real move has happened — that is what makes st.x/st.y correspond to
265
+ // where the engine's cursor actually is. The engine answers humanizedClick before its
266
+ // trajectory has finished, so without this the press can fire at a stale position.
267
+ try {
268
+ await nativeMove(st.x, st.y);
269
+ }
270
+ catch { /* best effort: keep the press on target */ }
271
+ }
272
+ st.held = true;
273
+ try {
274
+ return await nativeDown(...a);
275
+ }
276
+ catch (e) {
277
+ st.held = false; // a raising press must not strand held=true and make every later move a drag leg
278
+ throw e;
279
+ }
280
+ };
281
+ mouse.up = async (...a) => { try {
282
+ return await nativeUp(...a);
283
+ }
284
+ finally {
285
+ st.held = false;
286
+ } };
153
287
  mouse.click = async (x, y) => {
154
288
  if (await engineGlide(x, y, false))
155
289
  return;
@@ -164,30 +298,44 @@ export async function attachHumanize(browser, page, opts) {
164
298
  // Opt-in ambient / pre-challenge cursor activity: idle drift + a few non-goal moves so a behavioral
165
299
  // collector sees non-zero pointer entropy BEFORE the first goal action (the biggest reason a
166
300
  // challenge/slider is shown to headless automation). `await page.ambientMotion(ms)`.
167
- page.ambientMotion = async (ms = 1200) => {
168
- try {
169
- const vp = (page.viewportSize && page.viewportSize()) || { width: 1280, height: 800 };
170
- const steps = planAmbient(st, { width: vp.width, height: vp.height }, persona, ms);
171
- await dispatch(steps);
172
- if (steps.length) {
173
- st.x = steps[steps.length - 1].x;
174
- st.y = steps[steps.length - 1].y;
175
- }
176
- }
177
- catch { /* best-effort ambient */ }
178
- };
301
+ page.ambientMotion = async (ms = 1200) => { await ambient(ms); };
179
302
  // Held-button drag leg with the seating jiggle (settle), reachable from the module-level
180
303
  // Locator.dragTo patch (which can't see this closure) via the page object.
181
304
  page._clearcoteHeldGlide = async (x, y) => { await glide(x, y, { settle: true }); };
182
- // DEFAULT under humanize (no longer opt-in): fire a short ambient burst on every load.
183
- page._clearcoteAutoAmbient = true;
305
+ // OPT-IN: no ambient burst on load by default (it interleaves with caller-driven input,
306
+ // e.g. a slider drag). Call page.ambientMotion(ms) explicitly, or set
307
+ // page._clearcoteAutoAmbient = true to restore the per-load burst.
308
+ page._clearcoteAutoAmbient = false;
184
309
  page.on("load", () => {
185
310
  if (page._clearcoteAutoAmbient) {
186
311
  Promise.resolve(page.ambientMotion(rand(450, 950))).catch(() => { });
187
312
  }
188
313
  });
314
+ // A wheel event carries the pointer position, and until something moves the cursor that position
315
+ // is the default origin — so a scroll right after a page load reported the mouse parked in the
316
+ // corner, over nothing it was scrolling, while the deltas rolled down the middle of the document.
317
+ // Move to where someone reading this page would have left the cursor first.
318
+ //
319
+ // Only when the current position is unusable (never placed, or outside the viewport). A hand does
320
+ // not lift off the mouse between two scrolls of the same page, so re-homing on every wheel call
321
+ // would replace one tell with another.
322
+ const scrollAnchor = async () => {
323
+ try {
324
+ const vp = await viewport();
325
+ if (st.placed && st.x > 6 && st.y > 6 && st.x < vp.width - 6 && st.y < vp.height - 6)
326
+ return;
327
+ // Upper-middle of the reading column, gaussian-jittered: an exact viewport centre is as
328
+ // machine-made as the origin is, so the anchor has to be a distribution, not a landmark.
329
+ const x = clamp(vp.width * 0.48 + gauss() * vp.width * 0.10, vp.width * 0.18, vp.width * 0.82);
330
+ const y = clamp(vp.height * 0.36 + gauss() * vp.height * 0.09, vp.height * 0.14, vp.height * 0.62);
331
+ await glide(x, y);
332
+ await sleep(rand(60, 180)); // eyes land before the finger rolls the wheel
333
+ }
334
+ catch { /* best-effort: scroll from wherever the cursor is */ }
335
+ };
189
336
  // scroll easing: break into eased chunks of native wheel deltas
190
337
  mouse.wheel = async (dx, dy) => {
338
+ await scrollAnchor();
191
339
  const steps = Math.max(5, Math.min(24, Math.round((Math.abs(dx) + Math.abs(dy)) / 60)));
192
340
  const ease = (u) => 1 - Math.pow(1 - u, 2.2); // ease-OUT: fast flick -> slow inertial settle
193
341
  let px = 0, py = 0;
@@ -203,6 +351,14 @@ export async function attachHumanize(browser, page, opts) {
203
351
  await sleep(rand(10, 38));
204
352
  if (Math.random() < 0.07)
205
353
  await sleep(rand(40, 120)); // occasional mid-scroll pause (reading)
354
+ // A hand resting on the mouse does not hold it perfectly still through a long scroll. Without
355
+ // this a wheel burst is the one input where the pointer coordinate never changes at all.
356
+ if (steps >= 10 && i > 2 && i < steps && Math.random() < 0.06) {
357
+ try {
358
+ await glide(st.x + gauss() * 3.5, st.y + gauss() * 2.5);
359
+ }
360
+ catch { /* drift is cosmetic */ }
361
+ }
206
362
  }
207
363
  if (px !== dx || py !== dy)
208
364
  await nativeWheel(dx - px, dy - py);
@@ -256,7 +412,23 @@ export async function attachHumanize(browser, page, opts) {
256
412
  }
257
413
  }
258
414
  };
259
- keyboard.type = async (text) => { await humanTypeText(text); };
415
+ // Keystrokes from a session in which the mouse never existed. Selector-based typing glides to the
416
+ // field first (focusClick), but page.keyboard.type/press names no target, and on a page focused by
417
+ // autofocus or by Tab it can be the first input of any kind — a keydown stream with zero preceding
418
+ // pointer events is one of the cheapest checks a collector can run.
419
+ //
420
+ // AMBIENT ONLY. Deliberately NOT a move toward the focused field: the caller has already chosen
421
+ // where the text goes, and approaching that element would hover it (and, on a control that reacts
422
+ // to hover, move it) for no behavioral gain. Once per page — after that the cursor has a history,
423
+ // and warming up before every keystroke would itself be the anomaly.
424
+ let kbWarmed = false;
425
+ const keyboardWarmup = async () => {
426
+ if (kbWarmed || st.placed)
427
+ return;
428
+ kbWarmed = true;
429
+ await ambient(rand(280, 620));
430
+ };
431
+ keyboard.type = async (text) => { await keyboardWarmup(); await humanTypeText(text); };
260
432
  // keyboard.press with the persona's hold, unless the caller specified their own.
261
433
  //
262
434
  // WHY THIS EXISTS. keyboard.type was humanised from the start and keyboard.press was not, which
@@ -269,6 +441,7 @@ export async function attachHumanize(browser, page, opts) {
269
441
  // The caller's own `delay` wins: press(key, {delay}) is the documented way to hold a key for a
270
442
  // specific time, and silently overriding it would break that. Only the DEFAULT changes.
271
443
  keyboard.press = async (key, options = {}) => {
444
+ await keyboardWarmup();
272
445
  const opts = options && typeof options === "object" ? options : {};
273
446
  if (opts.delay === undefined)
274
447
  opts.delay = keyDwell(persona);
@@ -315,6 +488,7 @@ export async function attachHumanize(browser, page, opts) {
315
488
  const nativePageFill = page.fill.bind(page);
316
489
  const nativePagePress = page.press.bind(page);
317
490
  const nativePageDblclick = page.dblclick.bind(page);
491
+ const nativePageSelect = page.selectOption ? page.selectOption.bind(page) : null;
318
492
  page.type = async (selector, text, options = {}) => {
319
493
  try {
320
494
  const timeout = options.timeout ?? 30000;
@@ -382,6 +556,35 @@ export async function attachHumanize(browser, page, opts) {
382
556
  return nativePagePress(selector, key, options);
383
557
  }
384
558
  };
559
+ // page.selectOption was the last input method humanize did not touch, so a script that used it —
560
+ // rather than locator.selectOption, which has been patched for a while — went straight to
561
+ // Playwright's implementation: assign the value, dispatch input+change from page script. That is
562
+ // measurably scored: it fails 'interaction-select-change-trust' on clearcotelabs.com/audit,
563
+ // because no user gesture can produce a change event with isTrusted false.
564
+ //
565
+ // The move is a HOVER, not a click: clicking a <select> opens the native popup, and the popup
566
+ // swallows the arrow keys the trusted path steps the selection with. A person's pointer is on the
567
+ // dropdown they are choosing from either way.
568
+ if (nativePageSelect) {
569
+ page.selectOption = async (selector, values, options = {}) => {
570
+ const timeout = options.timeout ?? 30000;
571
+ try {
572
+ const pt = await pointFor(selector, timeout);
573
+ if (pt) {
574
+ await glide(pt.x, pt.y);
575
+ await sleep(rand(50, 150));
576
+ }
577
+ }
578
+ catch { /* the pre-move is best-effort; a trusted selection still beats a native one */ }
579
+ try {
580
+ const got = await trustedSelect(page, selector, values, timeout);
581
+ if (got)
582
+ return got;
583
+ }
584
+ catch { /* fall back */ }
585
+ return nativePageSelect(selector, values, options);
586
+ };
587
+ }
385
588
  const wrapTargeted = (name, noClick) => {
386
589
  const orig = page[name].bind(page);
387
590
  page[name] = async (selector, options = {}) => {
@@ -434,6 +637,84 @@ export async function attachHumanize(browser, page, opts) {
434
637
  page._clearcoteHumanized = true;
435
638
  patchLocatorClass(page.locator("html"));
436
639
  }
640
+ /** Choose a <select> option with the keyboard, so the ENGINE fires the events. Returns Playwright's
641
+ * `[value]` result once the selection is VERIFIED, or null when the keyboard route cannot be shown
642
+ * to have worked and the caller should fall back to native.
643
+ *
644
+ * Playwright's selectOption assigns the value and dispatches input+change from script. Those arrive
645
+ * with isTrusted false, which is the single most reliable dropdown tell there is — the engine cannot
646
+ * produce an untrusted change, so a page reading the flag knows the selection was not made by a
647
+ * person. A <select> that has focus and is CLOSED steps on ArrowUp/ArrowDown and the browser emits
648
+ * input then change itself, trusted, exactly as it does for a mouse. The fix is not to forge better
649
+ * events, it is to stop forging them.
650
+ *
651
+ * Returns null whenever that cannot be guaranteed: a multi- or disabled select, an option that
652
+ * cannot be resolved, an ElementHandle target, or a platform where arrows open the popup instead of
653
+ * stepping (macOS). selectedIndex is verified afterwards rather than assumed — a silently wrong
654
+ * selection is worse than an untrusted one. */
655
+ async function trustedSelect(page, selector, values, timeout) {
656
+ try {
657
+ const one = (v) => (Array.isArray(v) && v.length === 1 ? v[0] : v);
658
+ const v = one(values);
659
+ let by = null, want = null;
660
+ if (v && typeof v === "object" && !v.constructor?.name?.includes("ElementHandle")) {
661
+ if (v.index != null) {
662
+ by = "index";
663
+ want = v.index;
664
+ }
665
+ else if (v.label != null) {
666
+ by = "label";
667
+ want = v.label;
668
+ }
669
+ else if (v.value != null) {
670
+ by = "value";
671
+ want = v.value;
672
+ }
673
+ }
674
+ else if (typeof v === "string") {
675
+ by = "value";
676
+ want = v;
677
+ }
678
+ if (by === null)
679
+ return null;
680
+ const plan = await page.evaluate((a) => {
681
+ const el = globalThis.document.querySelector(a.sel);
682
+ if (!el || el.multiple || el.disabled)
683
+ return null;
684
+ const os = [...el.options];
685
+ let i = -1;
686
+ if (a.by === "index")
687
+ i = a.want >= 0 && a.want < os.length ? a.want : -1;
688
+ else if (a.by === "label")
689
+ i = os.findIndex((o) => (o.label || o.textContent || "").trim() === String(a.want).trim());
690
+ else
691
+ i = os.findIndex((o) => o.value === a.want);
692
+ if (i < 0 || os[i].disabled)
693
+ return null;
694
+ return { to: i, from: el.selectedIndex, ret: os[i].value };
695
+ }, { sel: selector, by, want });
696
+ if (!plan)
697
+ return null;
698
+ if (plan.to === plan.from)
699
+ return [plan.ret]; // already selected; forge nothing
700
+ await page.focus(selector, { timeout });
701
+ await sleep(rand(60, 160));
702
+ const step = plan.to > plan.from ? "ArrowDown" : "ArrowUp";
703
+ for (let i = 0; i < Math.abs(plan.to - plan.from); i++) {
704
+ // page.keyboard.press — humanized by attachHumanize, so the hold comes with it.
705
+ await page.keyboard.press(step);
706
+ await sleep(rand(45, 120));
707
+ }
708
+ const got = await page.evaluate((q) => {
709
+ const e = globalThis.document.querySelector(q);
710
+ return e ? e.selectedIndex : -1;
711
+ }, selector);
712
+ if (got === plan.to)
713
+ return [plan.ret];
714
+ }
715
+ catch { /* fall back */ }
716
+ return null;
717
+ }
437
718
  // --------------------------------------------------------------------------- locator patch
438
719
  let locatorPatched = false;
439
720
  /** Patch the Locator prototype once so locator.* interactions route through the humanized page.*
@@ -564,81 +845,12 @@ function patchLocatorClass(sample) {
564
845
  }
565
846
  return oUncheck.call(this, kw);
566
847
  };
567
- /** Choose a <select> option with the keyboard, so the ENGINE fires the events.
568
- *
569
- * Playwright's selectOption assigns the value and dispatches input+change from script. Those
570
- * arrive with isTrusted false, which is the single most reliable dropdown tell there is — the
571
- * engine cannot produce an untrusted change, so a page reading the flag knows the selection was
572
- * not made by a person. A <select> that has focus and is CLOSED steps on ArrowUp/ArrowDown and
573
- * the browser emits input then change itself, trusted, exactly as it does for a mouse. The fix
574
- * is not to forge better events, it is to stop forging them.
575
- *
576
- * Falls back to native whenever the keyboard route cannot be SHOWN to have worked: a multi- or
577
- * disabled select, an option that cannot be resolved, an ElementHandle target, or a platform
578
- * where arrows open the popup instead of stepping (macOS). selectedIndex is verified afterwards
579
- * rather than assumed — a silently wrong selection is worse than an untrusted one. */
580
848
  proto.selectOption = async function (values, kw = {}) {
849
+ // Routed through the page method (which glides to the control first) rather than calling
850
+ // trustedSelect straight, so a locator selection gets the same pre-move as a locator click.
581
851
  if (on(this)) {
582
852
  try {
583
- const one = (v) => (Array.isArray(v) && v.length === 1 ? v[0] : v);
584
- const v = one(values);
585
- let by = null, want = null;
586
- if (v && typeof v === "object" && !v.constructor?.name?.includes("ElementHandle")) {
587
- if (v.index != null) {
588
- by = "index";
589
- want = v.index;
590
- }
591
- else if (v.label != null) {
592
- by = "label";
593
- want = v.label;
594
- }
595
- else if (v.value != null) {
596
- by = "value";
597
- want = v.value;
598
- }
599
- }
600
- else if (typeof v === "string") {
601
- by = "value";
602
- want = v;
603
- }
604
- if (by !== null) {
605
- const page = this.page();
606
- const s = sel(this);
607
- const plan = await page.evaluate((a) => {
608
- const el = globalThis.document.querySelector(a.sel);
609
- if (!el || el.multiple || el.disabled)
610
- return null;
611
- const os = [...el.options];
612
- let i = -1;
613
- if (a.by === "index")
614
- i = a.want >= 0 && a.want < os.length ? a.want : -1;
615
- else if (a.by === "label")
616
- i = os.findIndex((o) => (o.label || o.textContent || "").trim() === String(a.want).trim());
617
- else
618
- i = os.findIndex((o) => o.value === a.want);
619
- if (i < 0 || os[i].disabled)
620
- return null;
621
- return { to: i, from: el.selectedIndex, ret: os[i].value };
622
- }, { sel: s, by, want });
623
- if (plan && plan.to === plan.from)
624
- return [plan.ret]; // already selected; forge nothing
625
- if (plan) {
626
- await this.focus();
627
- await sleep(rand(60, 160));
628
- const step = plan.to > plan.from ? "ArrowDown" : "ArrowUp";
629
- for (let i = 0; i < Math.abs(plan.to - plan.from); i++) {
630
- // page.keyboard.press — humanized above, so the hold comes with it.
631
- await page.keyboard.press(step);
632
- await sleep(rand(45, 120));
633
- }
634
- const got = await page.evaluate((q) => {
635
- const e = globalThis.document.querySelector(q);
636
- return e ? e.selectedIndex : -1;
637
- }, s);
638
- if (got === plan.to)
639
- return [plan.ret];
640
- }
641
- }
853
+ return await this.page().selectOption(sel(this), values, fwd(kw));
642
854
  }
643
855
  catch { /* fall back */ }
644
856
  }