clearcote 0.47.0 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/humanize.js CHANGED
@@ -4,13 +4,15 @@
4
4
  // dragging, scrolling and typing — dispatched as native trusted input (isTrusted===true,
5
5
  // navigator.webdriver stays false). It works whether you drive the page directly or via
6
6
  // locators:
7
- // page level — page.click / hover / dblclick / type / fill / press / selectOption,
8
- // page.mouse.move / click / wheel and held-button drags (down -> move -> up),
9
- // page.keyboard.type / press
7
+ // page level — page.click / hover / dblclick / type / fill / press / selectOption /
8
+ // check / uncheck / setChecked,
9
+ // page.mouse.move / click / dblclick / wheel and held-button drags
10
+ // (down -> move -> up), page.keyboard.type / press
10
11
  // locator level — locator.click / type / fill / hover / dblclick / press /
11
- // pressSequentially / clear / check / uncheck / tap / selectOption / dragTo
12
+ // pressSequentially / clear / check / uncheck / setChecked / tap /
13
+ // selectOption / dragTo
12
14
  // (routed through the humanized page methods; main-frame locators only,
13
- // native fallback)
15
+ // STRICT like Playwright, native fallback)
14
16
  // `showCursor: true` — injects a red cursor dot that follows the real mousemove events.
15
17
  //
16
18
  // Mouse moves build an eased, slightly bowed cubic-bezier path SDK-side from the tracked cursor
@@ -19,8 +21,14 @@
19
21
  // (slider captchas work) with no separate CDP channel to desync. Typing goes key-by-key through
20
22
  // the native keyboard (trusted; shift handled by the engine) with human inter-key timing. No
21
23
  // special binary command required.
24
+ //
25
+ // Form input follows sdk/python/clearcote/_humanize.py (the reference); WHAT to press comes from
26
+ // ./forms.ts (shared test vectors with Python and .NET), HOW comes from this file. Every DOM read
27
+ // goes through the isolated world (./isolated.ts) or Playwright's utility-world queries, never
28
+ // page.evaluate / locator.evaluate.
22
29
  import { makePersona, planMove, dragDwell, clickHold, keyDwell, clickPoint, planAmbient, gaussFrom, rolloverRate, wheelNotches, platformFromNavigator, WHEEL_NOTCH_PX, } from "./motion.js";
23
- import { COVER_CHECK_MS, ISO_IS_FOCUSED, ISO_PLATFORM, ISO_SELECT_PLAN, ISO_SELECTED_INDEX, ISO_VIEWPORT, worldFor } from "./isolated.js";
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";
24
32
  /** Overlay: a red dot that follows real mousemove events. Idempotent IIFE so it works both as
25
33
  * an addInitScript source string AND when passed to page.evaluate. */
26
34
  export const CURSOR_OVERLAY = `(() => {
@@ -95,23 +103,46 @@ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
95
103
  // for placing a point in a plausible spread instead of on an exact landmark.
96
104
  const gauss = () => gaussFrom(Math.random);
97
105
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
98
- // Compact keyboard-adjacency map for realistic fat-finger typos.
99
- const NEARBY = {
100
- a: "sqwz", b: "vghn", c: "xdfv", d: "sfecx", e: "wrsdf", f: "dgrtcv",
101
- g: "fhtyb", h: "gjybn", i: "ujko", j: "hkunm", k: "jloi", l: "kop",
102
- m: "njk", n: "bhjm", o: "iklp", p: "ol", q: "wa", r: "edft",
103
- s: "awedxz", t: "rfgy", u: "yhji", v: "cfgb", w: "qase", x: "zsdc",
104
- y: "tghu", z: "asx",
105
- };
106
- function nearbyKey(ch) {
107
- const lo = ch.toLowerCase();
108
- const n = NEARBY[lo];
109
- if (!n)
110
- return ch;
111
- const w = n[Math.floor(Math.random() * n.length)];
112
- return ch === ch.toUpperCase() && ch !== ch.toLowerCase() ? w.toUpperCase() : w;
106
+ // The letter-adjacency map now lives in ./forms.ts (shared with Python and .NET); kept under the old
107
+ // name for anything that imported it from here.
108
+ /** @internal */
109
+ export const NEARBY = NEARBY_LETTERS;
110
+ /** @internal exported for tests. Per-character fat-finger rate. CLEARCOTE_HUMANIZE_TYPO_RATE
111
+ * overrides it (0 disables typos; the test suites raise it to exercise the correction paths). Read at
112
+ * call time, like Python's typo_rate(). */
113
+ export function typoRate() {
114
+ const v = process.env.CLEARCOTE_HUMANIZE_TYPO_RATE;
115
+ if (v === undefined || v === "")
116
+ return DEFAULT_TYPO_RATE;
117
+ const f = pyFloat(v);
118
+ if (f === null || Number.isNaN(f))
119
+ return DEFAULT_TYPO_RATE;
120
+ return Math.min(1.0, Math.max(0.0, f));
121
+ }
122
+ /** @internal exported for tests. A Playwright timeout: the element never became actionable. Falling
123
+ * back to the native call after one would wait the FULL timeout again (a missing element took 2-3x
124
+ * `timeout` to fail), so timeouts propagate instead. */
125
+ export function isTimeout(err) {
126
+ return err?.name === "TimeoutError";
127
+ }
128
+ /** Internal: the humane route cannot apply here; run the native Playwright call instead. */
129
+ class NativeSignal extends Error {
130
+ constructor() { super("clearcote humanize: native route"); this.name = "NativeSignal"; }
131
+ }
132
+ /** @internal exported for tests. A per-page lock: each wrapped public entry point (page.click / fill
133
+ * / type / press / selectOption / check / uncheck / setChecked / dblclick / hover, mouse.click /
134
+ * dblclick, keyboard.type / press) runs one at a time. Without it a `Promise.all` of two fills on one
135
+ * page interleaved their clicks and keys and left each field with part of the other's text: a page has
136
+ * one pointer and one keyboard focus. Internal steps call internal helpers, never a locked entry point,
137
+ * so nothing waits on itself. A failed call releases the lock like a successful one. */
138
+ export function createInputLock() {
139
+ let tail = Promise.resolve();
140
+ return (fn) => {
141
+ const run = tail.then(() => fn());
142
+ tail = run.then(() => undefined, () => undefined);
143
+ return run;
144
+ };
113
145
  }
114
- const SELECT_ALL = "Control+a"; // Clearcote ships a Windows binary; Control is correct.
115
146
  /** Wrap a single page's input methods + (optionally) inject the cursor overlay. */
116
147
  export async function attachHumanize(browser, page, opts) {
117
148
  // IDEMPOTENCY GUARD — do not remove.
@@ -144,6 +175,8 @@ export async function attachHumanize(browser, page, opts) {
144
175
  // seeds. Stored on the page so the module-level Locator.dragTo patch can reach it too.
145
176
  const persona = makePersona(opts.seed);
146
177
  page._clearcotePersona = persona;
178
+ // One input action at a time on this page (see createInputLock).
179
+ const locked = createInputLock();
147
180
  // Tracked cursor position so each move continues from where the last one ended.
148
181
  // `held` tracks whether a mouse button is currently down — see mouse.move below.
149
182
  // `placed` records whether the engine's cursor has actually been moved yet. A page starts with the
@@ -337,17 +370,33 @@ export async function attachHumanize(browser, page, opts) {
337
370
  finally {
338
371
  st.held = false;
339
372
  } };
340
- mouse.click = async (x, y) => {
341
- if (await engineGlide(x, y, false))
373
+ // The engine path performs ONE plain left click. Anything else -- right/middle button, a double
374
+ // click, a caller-chosen hold -- glides SDK-side and presses as asked (pressAt); the old wrapper
375
+ // dropped those options and always left-clicked once.
376
+ const hclick = async (x, y, options = {}) => {
377
+ const o = normalizeClickOptions(options);
378
+ if (enginePathOk(o) && await engineGlide(x, y, false))
342
379
  return;
343
380
  await glide(x, y);
344
- await sleep(rand(40, 130)); // brief dwell before pressing, like a human
345
- // delay = the mousedown→mouseup HOLD (human ~60–150 ms); without it Playwright releases in ~2 ms.
346
381
  try {
347
- await nativeClick(x, y, { delay: clickHold(persona) });
382
+ if (o.modifiers.length && !(nativeKbDown && nativeKbUp)) {
383
+ await sleep(rand(40, 130)); // brief dwell before pressing, like a human
384
+ // delay = the mousedown→mouseup HOLD (human ~60–150 ms); without it Playwright releases in ~2 ms.
385
+ await nativeClick(x, y, { delay: o.delay ?? clickHold(persona), button: o.button, clickCount: o.clickCount });
386
+ return;
387
+ }
388
+ await pressAt(o);
348
389
  }
349
390
  catch { /* best-effort */ }
350
391
  };
392
+ mouse.click = (x, y, options = {}) => locked(() => hclick(x, y, options));
393
+ // Stock dblclick teleported and pressed for ~0 ms: route it through the humanized click.
394
+ if (typeof mouse.dblclick === "function") {
395
+ mouse.dblclick = (x, y, options = {}) => {
396
+ const { click_count: _cc, ...rest } = options ?? {};
397
+ return locked(() => hclick(x, y, { ...rest, clickCount: 2 }));
398
+ };
399
+ }
351
400
  // Opt-in ambient / pre-challenge cursor activity: idle drift + a few non-goal moves so a behavioral
352
401
  // collector sees non-zero pointer entropy BEFORE the first goal action (the biggest reason a
353
402
  // challenge/slider is shown to headless automation). `await page.ambientMotion(ms)`.
@@ -468,6 +517,136 @@ export async function attachHumanize(browser, page, opts) {
468
517
  catch { /* best-effort */ }
469
518
  }
470
519
  };
520
+ // Press `k` (n times) with the persona's hold on every press and a human gap between repeats.
521
+ const key = async (k, n = 1, gap = [60, 150]) => {
522
+ for (let j = 0; j < n; j++) {
523
+ await nativeKbPress(k, { delay: keyDwell(persona) });
524
+ if (j < n - 1)
525
+ await sleep(rand(gap[0], gap[1]));
526
+ }
527
+ };
528
+ // Press at the point the last glide ended, the way the options ask: the button, the click count (a
529
+ // 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
532
+ // down/up, so nothing moves.
533
+ const pressAt = async (o) => {
534
+ const held = [];
535
+ try {
536
+ for (const m of o.modifiers) {
537
+ await nativeKbDown(m);
538
+ held.push(m);
539
+ await sleep(rand(40, 110));
540
+ }
541
+ await sleep(rand(40, 130)); // brief dwell before pressing, like a human
542
+ const n = o.clickCount;
543
+ for (let i = 1; i <= n; i++) {
544
+ const hold = o.delay ?? clickHold(persona);
545
+ await nativeDown({ button: o.button, clickCount: i });
546
+ try {
547
+ await sleep(hold);
548
+ }
549
+ finally {
550
+ await nativeUp({ button: o.button, clickCount: i }); // never leave the button down
551
+ }
552
+ if (i < n)
553
+ await sleep(rand(70, 170)); // the gap inside a double click
554
+ }
555
+ }
556
+ finally {
557
+ for (const m of held.reverse()) {
558
+ await sleep(rand(20, 80));
559
+ try {
560
+ await nativeKbUp(m);
561
+ }
562
+ catch { /* best-effort */ }
563
+ }
564
+ }
565
+ };
566
+ // The focused field's current value, read in the isolated world (null if unknown).
567
+ const activeValue = async () => {
568
+ try {
569
+ const v = await worldFor(page).evaluate(ISO_ACTIVE_VALUE);
570
+ return typeof v === "string" ? v : null;
571
+ }
572
+ catch {
573
+ return null;
574
+ }
575
+ };
576
+ // Node's inter-key flight (the cadence humanTypeText uses between characters).
577
+ const flightMs = () => rand(45, 150);
578
+ const backspaces = async (n) => {
579
+ for (let k = 0; k < n; k++) {
580
+ await nativeKbPress("Backspace", { delay: keyDwell(persona) });
581
+ if (k < n - 1)
582
+ await sleep(rand(70, 150));
583
+ }
584
+ };
585
+ // How many characters arrived since `before` (null = cannot tell).
586
+ const landed = (before, now) => before === null || now === null ? null : Array.from(now).length - Array.from(before).length;
587
+ // Make the planned slip at chars[i] and correct it. Returns true when it already typed chars[i]
588
+ // itself (the doubled-key case), so the caller must not type it again.
589
+ //
590
+ // Only what actually LANDED is deleted: an input mask or a maxlength can swallow the wrong key, and
591
+ // a Backspace pressed for a key that never arrived deletes a real character (the
592
+ // letter-typo-in-a-numeric-mask failure). Measured before and after the slip, in the isolated world,
593
+ // one read each -- typos are ~2 % of keys, so this costs nearly nothing.
594
+ const doTypo = async (chars, i, tp) => {
595
+ const before = await activeValue();
596
+ if (tp.kind === "sub") {
597
+ await emitKey(tp.wrong);
598
+ let got = landed(before, await activeValue());
599
+ if (got !== null && got <= 0)
600
+ return false; // swallowed: nothing to correct
601
+ let typed = 1;
602
+ for (const c of chars.slice(i + 1, i + 1 + tp.late)) { // late notice: a key or two more
603
+ await sleep(flightMs());
604
+ await emitKey(c);
605
+ typed++;
606
+ }
607
+ await sleep(rand(140, 340)); // noticing
608
+ got = landed(before, await activeValue());
609
+ await backspaces(got === null ? typed : Math.max(0, Math.min(typed, got)));
610
+ await sleep(rand(80, 200));
611
+ return false;
612
+ }
613
+ if (tp.kind === "double") {
614
+ await emitKey(chars[i]);
615
+ await sleep(rand(45, 95)); // the bounce comes fast
616
+ await emitKey(chars[i]);
617
+ const got = landed(before, await activeValue());
618
+ await sleep(rand(140, 340));
619
+ const extra = got === null ? 1 : Math.max(0, got - 1);
620
+ if (extra) {
621
+ await backspaces(extra);
622
+ await sleep(rand(80, 200));
623
+ }
624
+ return true;
625
+ }
626
+ if (tp.kind === "swap") {
627
+ await emitKey(chars[i + 1]);
628
+ await sleep(flightMs());
629
+ await emitKey(chars[i]);
630
+ const got = landed(before, await activeValue());
631
+ await sleep(rand(140, 340));
632
+ await backspaces(got === null ? 2 : Math.max(0, Math.min(2, got)));
633
+ await sleep(rand(80, 200));
634
+ return false;
635
+ }
636
+ return false;
637
+ };
638
+ // What the typo gate needs about the field keys go to: the caller's description, else the focused
639
+ // element read in the isolated world.
640
+ const typingField = async (field) => {
641
+ if (field !== null && field !== undefined)
642
+ return field;
643
+ try {
644
+ return (await worldFor(page).evaluate(ISO_ACTIVE_FIELD)) ?? null;
645
+ }
646
+ catch {
647
+ return null;
648
+ }
649
+ };
471
650
  // Type text key-by-key with human timing. Each char goes through the native keyboard (trusted),
472
651
  // so this stays isTrusted===true.
473
652
  //
@@ -479,11 +658,16 @@ export async function attachHumanize(browser, page, opts) {
479
658
  // Fast pairs ROLL OVER: the next key goes down before the previous one comes up. One press() per
480
659
  // character never overlaps (0 of 32 keydowns, measured on r32/r33), a keystroke-dynamics tell;
481
660
  // real typists overlap many fast pairs. The share is the persona's rolloverRate, stable per seed.
661
+ //
662
+ // Fat-finger typos are planned by forms.planTypo and never happen where a slip is a failure rather
663
+ // than a slip (passwords, e-mail, numbers, phone numbers, URLs, one-time codes).
482
664
  const rollRate = rolloverRate(persona);
483
- const humanTypeText = async (text) => {
665
+ const humanTypeText = async (text, field, typos = true) => {
484
666
  const chars = Array.from(text);
485
667
  const n = chars.length;
486
668
  let shift = false;
669
+ const rate = typos ? typoRate() : 0;
670
+ const allow = rate > 0 && typosAllowed(await typingField(field));
487
671
  try {
488
672
  for (let i = 0; i < n; i++) {
489
673
  let ch = chars[i];
@@ -493,17 +677,18 @@ export async function attachHumanize(browser, page, opts) {
493
677
  shift = true;
494
678
  await sleep(rand(35, 120));
495
679
  }
496
- // occasional fat-finger on unshifted alnum chars, then notice + correct
680
+ // occasional fat-finger on unshifted letters and digits, then notice + correct
497
681
  let typo = false;
498
- if (!need && /[a-z0-9]/.test(ch) && Math.random() < 0.02) {
499
- typo = true;
500
- try {
501
- await emitKey(nearbyKey(ch));
502
- await sleep(rand(120, 300));
503
- await nativeKbPress("Backspace", { delay: keyDwell(persona) });
504
- await sleep(rand(80, 200));
682
+ let consumed = false;
683
+ if (allow && !need && !shift && typoEligible(ch)) {
684
+ const tp = planTypo(chars, i, Math.random, rate);
685
+ if (tp !== null) {
686
+ typo = true;
687
+ try {
688
+ consumed = await doTypo(chars, i, tp);
689
+ }
690
+ catch { /* typo path best-effort */ }
505
691
  }
506
- catch { /* typo path best-effort */ }
507
692
  }
508
693
  // Rollover of ch and the next char: unshifted single-key pairs only, never a key with itself,
509
694
  // never after a typo. keyboard.down inserts the character exactly as press does, so the typed
@@ -541,7 +726,7 @@ export async function attachHumanize(browser, page, opts) {
541
726
  ch = nx; // nx is typed; the cadence below runs as if after it
542
727
  }
543
728
  }
544
- if (!rolled) {
729
+ if (!rolled && !consumed) {
545
730
  try {
546
731
  await emitKey(ch);
547
732
  }
@@ -558,7 +743,7 @@ export async function attachHumanize(browser, page, opts) {
558
743
  if (Math.random() < 0.06)
559
744
  await sleep(rand(180, 450)); // brief thinking pause
560
745
  else
561
- await sleep(rand(45, 150));
746
+ await sleep(flightMs());
562
747
  if (/\s/.test(ch))
563
748
  await sleep(rand(20, 100)); // slight extra pause at word boundaries
564
749
  }
@@ -590,7 +775,7 @@ export async function attachHumanize(browser, page, opts) {
590
775
  kbWarmed = true;
591
776
  await ambient(rand(280, 620));
592
777
  };
593
- keyboard.type = async (text) => { await keyboardWarmup(); await humanTypeText(text); };
778
+ keyboard.type = (text) => locked(async () => { await keyboardWarmup(); await humanTypeText(text); });
594
779
  // keyboard.press with the persona's hold, unless the caller specified their own.
595
780
  //
596
781
  // WHY THIS EXISTS. keyboard.type was humanised from the start and keyboard.press was not, which
@@ -601,261 +786,993 @@ export async function attachHumanize(browser, page, opts) {
601
786
  // session sees the press path — so one un-humanised call undid the whole keyboard persona.
602
787
  //
603
788
  // The caller's own `delay` wins: press(key, {delay}) is the documented way to hold a key for a
604
- // specific time, and silently overriding it would break that. Only the DEFAULT changes.
605
- keyboard.press = async (key, options = {}) => {
789
+ // specific time, and silently overriding it would break that. Only the DEFAULT changes -- and
790
+ // `delay: 0` IS Playwright's default ("no hold"), so it, like any non-positive value, gets the
791
+ // persona's hold too (a 0 ms key hold is the tell this wrapper exists to remove).
792
+ keyboard.press = (key, options = {}) => locked(async () => {
606
793
  await keyboardWarmup();
607
- const opts = options && typeof options === "object" ? options : {};
608
- if (opts.delay === undefined)
794
+ const opts = { ...(options && typeof options === "object" ? options : {}) };
795
+ if (!(typeof opts.delay === "number" && opts.delay > 0))
609
796
  opts.delay = keyDwell(persona);
610
797
  return nativeKbPress(key, opts);
611
- };
798
+ });
612
799
  // ------------------------------------------------------- page-level targeted helpers
613
- // Read in an isolated world (./isolated.ts): the page never sees the lookup.
614
- const isFocused = async (selector) => Boolean(await worldFor(page).evaluate(ISO_IS_FOCUSED, selector));
615
- const pointFor = async (selector, timeout) => {
616
- const loc = page.locator(selector).first();
617
- await loc.waitFor({ state: "visible", timeout });
618
- await loc.scrollIntoViewIfNeeded({ timeout });
619
- if (!(await loc.isEnabled()))
800
+ // Points tried when resolving a control through the browser's hit test: a floating label or an icon
801
+ // often sits over a field's CENTRE, so the edges get a chance too.
802
+ const probePoints = [[0.5, 0.5], [0.82, 0.5], [0.18, 0.5], [0.5, 0.8], [0.5, 0.2]];
803
+ // The control filling `box` (Playwright's resolved box for a locator), read in the isolated world
804
+ // through the browser's own hit test: works for every Playwright selector (getByRole, getByLabel,
805
+ // `>> nth=`, .filter()) where document.querySelector threw and dropped the call to stock input. The
806
+ // element found must fill the SAME box, or it is not the one the locator means; null when no probe
807
+ // point proves that. `probe` records the point that proved it.
808
+ const describe = async (box, want = ISO_FORM_CONTROL) => {
809
+ if (!box || box.width <= 0 || box.height <= 0)
620
810
  return null;
621
- let box = await loc.boundingBox();
622
- if (!box)
811
+ const w = worldFor(page);
812
+ const scroll = await w.scroll(); // once: the hit test wants document coordinates
813
+ for (const [fx, fy] of probePoints) {
814
+ const nid = await w.nodeAt(box.x + box.width * fx, box.y + box.height * fy, scroll);
815
+ if (nid === undefined)
816
+ continue;
817
+ const info = await w.callOnNode(nid, ISO_DESCRIBE, { want });
818
+ if (!info || !info.rect)
819
+ continue;
820
+ const [rx, ry, rw, rh] = info.rect;
821
+ if (Math.abs(rx - box.x) <= 1.5 && Math.abs(ry - box.y) <= 1.5
822
+ && Math.abs(rw - box.width) <= 1.5 && Math.abs(rh - box.height) <= 1.5) {
823
+ info.node = nid;
824
+ // The point that PROVED the control: when the centre is covered (a floating label), this is
825
+ // where a click actually reaches it.
826
+ info.probe = [fx, fy];
827
+ return info;
828
+ }
829
+ }
830
+ return null;
831
+ };
832
+ // A click point the hit test proved reaches the control, when its centre did not; null when the
833
+ // centre itself was fine (then the persona's own point is used, as for any click).
834
+ const provenPoint = (box, info) => {
835
+ if (!info || !info.probe || (info.probe[0] === 0.5 && info.probe[1] === 0.5))
836
+ return null;
837
+ const [fx, fy] = info.probe;
838
+ const x = box.x + box.width * fx + gauss() * 1.5;
839
+ const y = box.y + box.height * fy + gauss() * 1.0;
840
+ return [clamp(x, box.x + 1, box.x + box.width - 1), clamp(y, box.y + 1, box.y + box.height - 1)];
841
+ };
842
+ // Describe the SAME element again, from the node the first hit test found: no new Playwright handle
843
+ // (see boxOf), so nothing the page can see. null if it is gone.
844
+ const redescribe = async (info, want = ISO_FORM_CONTROL) => {
845
+ if (info.node === undefined)
846
+ return null;
847
+ const again = await worldFor(page).callOnNode(info.node, ISO_DESCRIBE, { want });
848
+ if (!again)
623
849
  return null;
624
- await sleep(50); // stability: a box that's still moving = an animation in flight
625
- const box2 = await loc.boundingBox();
626
- if (!box2 || Math.abs(box2.x - box.x) > 1 || Math.abs(box2.y - box.y) > 1)
850
+ again.node = info.node;
851
+ again.probe = info.probe ?? [0.5, 0.5];
852
+ return again;
853
+ };
854
+ const inView = async (box) => {
855
+ const vp = await viewport();
856
+ return box.x >= 0 && box.y >= 0 && box.x + box.width <= vp.width && box.y + box.height <= vp.height;
857
+ };
858
+ const rectBox = (r) => {
859
+ if (!r || !Array.isArray(r.rect))
860
+ return null;
861
+ const [x, y, width, height] = r.rect;
862
+ return width > 0 && height > 0 ? { x, y, width, height } : null;
863
+ };
864
+ // 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) => {
867
+ const w = worldFor(page);
868
+ const nid = await w.nodeAt(x, y);
869
+ if (nid === undefined)
870
+ 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 });
872
+ return res === undefined || res === null ? null : Boolean(res);
873
+ };
874
+ // Does the element filling `box` own its centre -- not clipped by a scrolling container, not
875
+ // 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;
877
+ // The box of a plain-CSS selector's first match, read (and scrolled into view) in the isolated
878
+ // world: not even the one boundingBox() handle, and its textContent preview, is made. Used only
879
+ // when Playwright counts exactly the elements the browser's own querySelectorAll finds --
880
+ // Playwright's CSS also pierces open shadow roots, so equal counts mean the same elements in the same
881
+ // order. null otherwise (getByRole, text=, `>>` chains, :has-text()...: then Playwright's boundingBox).
882
+ const cssBox = async (selector) => {
883
+ if (typeof selector !== "string" || !selector)
884
+ return null;
885
+ const w = worldFor(page);
886
+ const r = await w.evaluate(ISO_CSS_BOX, { sel: selector, scroll: false });
887
+ if (!r || !r.n)
888
+ return null;
889
+ try {
890
+ if ((await page.locator(selector).count()) !== r.n)
891
+ return null;
892
+ }
893
+ catch {
627
894
  return null;
628
- box = box2;
629
- return clickPoint(box, st, persona); // 2D gaussian toward center, nudged to the approach side
895
+ }
896
+ let box = rectBox(r);
897
+ if (box && !((await inView(box)) && (await hitOk(box)))) {
898
+ // scrollIntoViewIfNeeded also scrolls a scrolling container that clips it, and does nothing
899
+ // when it already shows (then the cover is an overlay: the click's own check)
900
+ box = rectBox(await w.evaluate(ISO_CSS_BOX, { sel: selector, scroll: true }));
901
+ }
902
+ return box;
903
+ };
904
+ // Scroll the WINDOW so `box` sits centred where it was off-screen, the way Playwright's scroll
905
+ // centres it, and return where the box is now (moved by exactly the scrolled distance) -- or null.
906
+ // Needs no handle: the box is already known.
907
+ const scrollWindowTo = async (box) => {
908
+ const vp = await viewport();
909
+ if (box.width > vp.width || box.height > vp.height)
910
+ return null;
911
+ const cx = box.x + box.width / 2, cy = box.y + box.height / 2;
912
+ const dx = box.x < 0 || box.x + box.width > vp.width ? cx - vp.width / 2 : 0;
913
+ const dy = box.y < 0 || box.y + box.height > vp.height ? cy - vp.height / 2 : 0;
914
+ const moved = await worldFor(page).evaluate(ISO_SCROLL_BY, [dx, dy]);
915
+ if (!Array.isArray(moved) || moved.length !== 2)
916
+ return null;
917
+ return { x: box.x - Number(moved[0]), y: box.y - Number(moved[1]), width: box.width, height: box.height };
918
+ };
919
+ // Wait the way Playwright would, with the CALLER's timeout (undefined = the page default; a
920
+ // hard-coded 30 s used to override setDefaultTimeout both ways), and return the box, in view. With
921
+ // `force` Playwright skips actionability, so nothing is waited for: a hidden target goes native at
922
+ // once instead of timing out where the native call would have acted.
923
+ //
924
+ // Each Playwright call that resolves the element to a handle (waitFor, scrollIntoViewIfNeeded,
925
+ // boundingBox) makes a MAIN-world handle whose preview reads the element's textContent where the
926
+ // page can see it: verifier B counted 4 such reads per humanized click and 7 per textarea fill. So
927
+ // each is made only when needed: the wait only for an element not visible yet (isVisible runs in
928
+ // Playwright's utility world), the scroll only for a box not already inside the viewport, and a
929
+ // plain-CSS `selector` needs no handle at all (cssBox). What remains, for other selectors, is the
930
+ // one boundingBox.
931
+ const boxOf = async (loc, timeout, force = false, selector = null) => {
932
+ if (!(await loc.isVisible())) {
933
+ if (force)
934
+ throw new NativeSignal();
935
+ await loc.waitFor({ state: "visible", timeout });
936
+ }
937
+ const css = await cssBox(selector);
938
+ if (css)
939
+ return css;
940
+ let box = await loc.boundingBox();
941
+ if (box && !((await inView(box)) && (await hitOk(box)))) {
942
+ // Off-screen: scroll the window to it and prove it shows there (hit test), with no new handle.
943
+ // Clipped by a scrolling container, covered, or the window scroll did not bring it out:
944
+ // Playwright's scroll, which also scrolls containers, and a fresh box.
945
+ const moved = !(await inView(box)) ? await scrollWindowTo(box) : null;
946
+ if (moved && (await inView(moved)) && (await hitOk(moved)))
947
+ return moved;
948
+ await loc.scrollIntoViewIfNeeded({ timeout });
949
+ box = await loc.boundingBox();
950
+ }
951
+ return box;
630
952
  };
631
- const focusClick = async (selector, timeout) => {
632
- const pt = await pointFor(selector, timeout);
633
- if (!pt)
953
+ // Does `loc` hold the keyboard focus? Asked through Playwright's utility-world selector engine
954
+ // (`:focus`), so it works for every element and every locator and the page sees nothing. The
955
+ // isolated hit test only knew form controls, so a focused BUTTON read as unfocused and got clicked
956
+ // before a press -- two activations.
957
+ const focused = async (loc) => {
958
+ try {
959
+ return (await loc.and(page.locator(":focus")).count()) > 0;
960
+ }
961
+ catch {
634
962
  return false;
963
+ }
964
+ };
965
+ const isFocused = (selector) => focused(page.locator(selector).first());
966
+ // {box, info} when `loc` is a visible text-entry control a person would click into before typing,
967
+ // else null. Anything else -- a button, a link, a hidden helper textarea, a slider -- is left to
968
+ // Playwright, which focuses it without a click (a click on a button activates it; on a slider it
969
+ // moves it).
970
+ const textEntry = async (loc, timeout, selector = null) => {
971
+ if (!(await loc.isVisible()))
972
+ return null;
973
+ const box = await boxOf(loc, timeout, false, selector);
974
+ const info = await describe(box);
975
+ if (box && info && classifyField(info.tag, info.type, info.ce) === "text")
976
+ return { box, info };
977
+ return null;
978
+ };
979
+ // Click into the control filling `box` (resolved, visible and in view). `at`: the point the hit test
980
+ // proved when the centre is covered (a floating label); else the persona's point. Stability is
981
+ // checked in the isolated world -- the control must still fill the same box 50 ms later (an
982
+ // animation in flight goes native) -- not by a second boundingBox(), which the page could see (see
983
+ // boxOf).
984
+ //
985
+ // A control no probe point reaches -- a label or an icon covers all of it -- is clicked anyway, where
986
+ // a person clicks (a floating label passes the focus on), and the caller checks that the caret
987
+ // arrived. Its stability then comes from a fresh box: the isolated CSS read, else boundingBox().
988
+ // Requiring the hit test there sent a fully labelled field to Playwright's insertText (found by the
989
+ // .NET port).
990
+ const focusClick = async (box, at = null, loc = null, selector = null) => {
991
+ if (!box)
992
+ return false;
993
+ await sleep(50);
994
+ const info = await describe(box);
995
+ if (!info) {
996
+ let again = selector ? await cssBox(selector) : null;
997
+ if (!again && loc)
998
+ again = await loc.boundingBox();
999
+ if (!again || Math.abs(again.x - box.x) > 1 || Math.abs(again.y - box.y) > 1)
1000
+ return false;
1001
+ }
1002
+ else if (info.disabled) {
1003
+ return false;
1004
+ }
1005
+ const pt = at ? { x: at[0], y: at[1] } : clickPoint(box, st, persona); // 2D gaussian toward the centre
635
1006
  await glide(pt.x, pt.y);
636
1007
  await sleep(rand(40, 130));
637
1008
  await nativeClick(pt.x, pt.y, { delay: clickHold(persona) });
638
1009
  return true;
639
1010
  };
1011
+ // Ctrl+A (Cmd+A on a Mac engine) the way a hand does it: the modifier goes down a moment before the
1012
+ // A, the A is held, the modifier comes up just after. Sent as one Playwright chord the modifier and
1013
+ // the A went down within ~1 ms of each other. ControlOrMeta lets Playwright pick the chord the
1014
+ // ENGINE's editing honours: a Meta+A sent to a Linux engine for a macOS persona selects nothing.
1015
+ const selectAll = async () => {
1016
+ if (!nativeKbDown || !nativeKbUp) {
1017
+ await key("ControlOrMeta+a");
1018
+ return;
1019
+ }
1020
+ await nativeKbDown("ControlOrMeta");
1021
+ try {
1022
+ await sleep(rand(40, 110));
1023
+ await nativeKbPress("a", { delay: keyDwell(persona) });
1024
+ await sleep(rand(20, 80));
1025
+ }
1026
+ finally {
1027
+ await nativeKbUp("ControlOrMeta");
1028
+ }
1029
+ };
1030
+ // Select all, then Backspace -- each key HELD like a finger holds it. The old clear sent Control+a
1031
+ // and Backspace with ~0 ms holds, which is exactly what a key-dwell check flags. If the field is
1032
+ // still not empty (`read` -- the field's value -- says so), it is emptied from the end, one
1033
+ // Backspace per character.
1034
+ const clearFocused = async (read = activeValue) => {
1035
+ await selectAll();
1036
+ await sleep(rand(60, 160));
1037
+ await key("Backspace");
1038
+ await sleep(rand(60, 160));
1039
+ const left = await read();
1040
+ if (left) {
1041
+ const len = Array.from(left).length;
1042
+ if (len > 64)
1043
+ throw new Error("field would not clear");
1044
+ await key("End");
1045
+ await sleep(rand(80, 180));
1046
+ await key("Backspace", len, [55, 120]);
1047
+ await sleep(rand(60, 160));
1048
+ if (await read())
1049
+ throw new Error("field would not clear");
1050
+ }
1051
+ };
640
1052
  const nativePageType = page.type ? page.type.bind(page) : null;
641
1053
  const nativePageFill = page.fill.bind(page);
642
1054
  const nativePagePress = page.press.bind(page);
643
1055
  const nativePageDblclick = page.dblclick.bind(page);
644
- const nativePageSelect = page.selectOption ? page.selectOption.bind(page) : null;
645
- page.type = async (selector, text, options = {}) => {
1056
+ const nativePageClick = page.click.bind(page);
1057
+ const nativePageHover = page.hover.bind(page);
1058
+ const nativePageSelect = typeof page.selectOption === "function" ? page.selectOption.bind(page) : null;
1059
+ const nativeType = (selector, text, options) => nativePageType ? nativePageType(selector, text, options) : nativePageFill(selector, text, options);
1060
+ // Make `selector` the focus the way a person does (a click), unless it already is. Only text-entry
1061
+ // controls are clicked; for anything else, and when the click did not actually put the caret there
1062
+ // (an overlay took it), the caller goes native.
1063
+ const clickInto = async (selector, timeout) => {
1064
+ const loc = page.locator(selector).first();
1065
+ if (await focused(loc))
1066
+ return;
1067
+ const te = await textEntry(loc, timeout, selector);
1068
+ if (!te || !(await focusClick(te.box, provenPoint(te.box, te.info), loc, selector)) || !(await focused(loc))) {
1069
+ throw new NativeSignal();
1070
+ }
1071
+ await sleep(rand(40, 120));
1072
+ };
1073
+ const htype = async (selector, text, options) => {
646
1074
  try {
647
- const timeout = options.timeout ?? 30000;
648
- if (!(await isFocused(selector))) {
649
- if (!(await focusClick(selector, timeout))) {
650
- return nativePageType ? nativePageType(selector, text, options) : nativePageFill(selector, text, options);
1075
+ await clickInto(selector, options?.timeout);
1076
+ await humanTypeText(text);
1077
+ return;
1078
+ }
1079
+ catch (e) {
1080
+ if (isTimeout(e))
1081
+ throw e;
1082
+ }
1083
+ // outside the try: a native call that raises must raise ONCE, not be retried by the handler
1084
+ return nativeType(selector, text, options);
1085
+ };
1086
+ page.type = (selector, text, options = {}) => locked(() => htype(selector, text, options));
1087
+ // Type `value` into a text control and PROVE it landed. The value is read through Playwright's
1088
+ // utility world (`inputValue`): it sees the control the locator means, wherever focus is, and the page
1089
+ // cannot observe it. A field whose value cannot be read, or a click that did not put the caret in it
1090
+ // (an overlay took it), is left to Playwright -- "could not check" is never treated as success.
1091
+ 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
+ const isCe = Boolean(info && info.ce)
1095
+ || (info === null && ((await loc.getAttribute("contenteditable")) || "false") !== "false");
1096
+ if (!(await focusClick(box, box ? provenPoint(box, info) : null, loc, selector)))
1097
+ return false;
1098
+ await sleep(rand(40, 120));
1099
+ if (!(await focused(loc)))
1100
+ return false;
1101
+ const read = async () => {
1102
+ try {
1103
+ return await loc.inputValue({ timeout: COVER_RETRY_MS });
1104
+ }
1105
+ catch {
1106
+ return null;
1107
+ }
1108
+ };
1109
+ if (isCe) {
1110
+ const cur = info ? info.value : null;
1111
+ if (cur)
1112
+ await clearFocused();
1113
+ await humanTypeText(value, info);
1114
+ return true; // contenteditable: Playwright's fill sets text the same way; no value to read
1115
+ }
1116
+ const cur = await read();
1117
+ if (cur === null)
1118
+ return false;
1119
+ if (cur)
1120
+ await clearFocused(read); // a person does not select-all an empty field
1121
+ await humanTypeText(value, info);
1122
+ let got = await read();
1123
+ if (got === null)
1124
+ return false;
1125
+ if (got === value || formattedEqual(got, value))
1126
+ return true; // exact, or a mask that only re-punctuated it
1127
+ // Wrong value (an autocompleter interfered, a key was lost): one clean retry without slips, then
1128
+ // give the field to Playwright -- a correct value beats pretty input.
1129
+ await clearFocused(read);
1130
+ await humanTypeText(value, info, false);
1131
+ got = await read();
1132
+ return got !== null && (got === value || formattedEqual(got, value));
1133
+ };
1134
+ // A range input: drag the thumb (or click the track) to the value, then arrow-nudge to it exactly.
1135
+ // Typing digits into a slider -- what fill() did before -- changes nothing, and the click before it
1136
+ // left the thumb wherever it landed, with no error.
1137
+ const fillRange = async (loc, box, info0, value) => {
1138
+ const info = info0 ?? {
1139
+ min: await loc.getAttribute("min"), max: await loc.getAttribute("max"),
1140
+ step: await loc.getAttribute("step"), value: await loc.inputValue(), dir: "ltr", wm: "",
1141
+ };
1142
+ // Off the step, out of range, not a number: Playwright raises "Malformed value" for these, so does
1143
+ // this route (by leaving them to it) -- never a silently snapped or clamped value.
1144
+ if (!rangeValueOk(value, info.min, info.max, info.step))
1145
+ return false;
1146
+ if ((info.wm || "").startsWith("vertical"))
1147
+ return false;
1148
+ const rtl = info.dir === "rtl";
1149
+ const [lo, hi, step] = rangeAttrs(info.min, info.max, info.step);
1150
+ const target = rangeSanitize(value, info.min, info.max, info.step);
1151
+ const now = async () => {
1152
+ const f = pyFloat(await loc.inputValue());
1153
+ return f === null || Number.isNaN(f) ? null : f;
1154
+ };
1155
+ const cur = await now();
1156
+ if (cur !== null && numericEqual(cur, target))
1157
+ return true;
1158
+ let [tx, ty] = rangePoint(box, rangeFraction(target, lo, hi), rtl);
1159
+ ty += gauss() * box.height * 0.08;
1160
+ if (cur !== null && Math.random() < 0.65) {
1161
+ // Grab the thumb where it is and slide it: what most hands do with a slider.
1162
+ let [cx, cy] = rangePoint(box, rangeFraction(cur, lo, hi), rtl);
1163
+ cy += gauss() * box.height * 0.08;
1164
+ await glide(cx, cy, { targetW: 16 });
1165
+ await sleep(rand(60, 160));
1166
+ const { grabMs, releaseMs } = dragDwell(persona);
1167
+ await nativeDown();
1168
+ st.held = true;
1169
+ try {
1170
+ await sleep(grabMs);
1171
+ await glide(tx, ty, { settle: true });
1172
+ await sleep(releaseMs);
1173
+ }
1174
+ finally {
1175
+ await nativeUp();
1176
+ st.held = false;
1177
+ }
1178
+ }
1179
+ else {
1180
+ await glide(tx, ty, { targetW: 16 });
1181
+ await sleep(rand(40, 130));
1182
+ await nativeClick(tx, ty, { delay: clickHold(persona) });
1183
+ }
1184
+ for (let round = 0; round < 3; round++) {
1185
+ const v = await now();
1186
+ if (v === null)
1187
+ return false;
1188
+ if (numericEqual(v, target))
1189
+ return true;
1190
+ let [k, n] = rangeNudge(v, target, step);
1191
+ if (k === null || n <= 0 || n > 40)
1192
+ break;
1193
+ if (rtl)
1194
+ k = k === "ArrowRight" ? "ArrowLeft" : "ArrowRight";
1195
+ await sleep(rand(150, 350));
1196
+ await key(k, n);
1197
+ }
1198
+ const v = await now();
1199
+ return v !== null && numericEqual(v, target);
1200
+ };
1201
+ // A native date / time / month / week / datetime-local editor: click into its first field and type
1202
+ // each field in the order the editor itself shows them (read from its UA shadow tree over CDP --
1203
+ // the order follows the browser, not the persona, so it is never assumed).
1204
+ const fillDatetime = async (loc, box, info, value) => {
1205
+ if (!info || !info.node)
1206
+ return false;
1207
+ const kind = info.type;
1208
+ const w = worldFor(page);
1209
+ const tree = await w.send("DOM.describeNode", { backendNodeId: info.node, depth: -1, pierce: true });
1210
+ if (!tree || !tree.node)
1211
+ return false;
1212
+ const segs = segmentNodesFromTree(tree.node);
1213
+ const plan = planDatetime(kind, segs.map(([s]) => s), value, info.value);
1214
+ if (!plan || plan.length === 0 || segs.length === 0)
1215
+ return false;
1216
+ let px = null, py = 0, fw = 12.0;
1217
+ const bm = segs[0][1] ? await w.send("DOM.getBoxModel", { backendNodeId: segs[0][1] }) : undefined;
1218
+ if (bm && bm.model) {
1219
+ const c = bm.model.content;
1220
+ const xs = [c[0], c[2], c[4], c[6]], ys = [c[1], c[3], c[5], c[7]];
1221
+ const x0 = Math.min(...xs), y0 = Math.min(...ys), x1 = Math.max(...xs), y1 = Math.max(...ys);
1222
+ // trust the field box only when it lies inside the input's own box
1223
+ if (box.x - 1 <= x0 && x0 < x1 && x1 <= box.x + box.width + 1
1224
+ && box.y - 1 <= y0 && y0 < y1 && y1 <= box.y + box.height + 1) {
1225
+ px = rand(x0 + (x1 - x0) * 0.25, x0 + (x1 - x0) * 0.75);
1226
+ py = (y0 + y1) / 2 + gauss() * Math.max(0.5, (y1 - y0) * 0.1);
1227
+ fw = Math.max(6.0, x1 - x0);
1228
+ }
1229
+ }
1230
+ if (px === null) {
1231
+ px = box.x + Math.min(14.0, box.width * 0.12);
1232
+ py = box.y + box.height / 2;
1233
+ }
1234
+ await glide(px, py, { targetW: fw });
1235
+ await sleep(rand(40, 130));
1236
+ await nativeClick(px, py, { delay: clickHold(persona) });
1237
+ await sleep(rand(150, 350));
1238
+ for (const a of plan) {
1239
+ 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));
651
1245
  }
652
- await sleep(rand(40, 120));
653
1246
  }
654
- await humanTypeText(text);
1247
+ else {
1248
+ await key(a[1], a.length > 2 ? a[2] : 1, [70, 170]);
1249
+ }
1250
+ await sleep(rand(60, 220)); // between fields: the eye checks what it typed
655
1251
  }
656
- catch {
657
- return nativePageType ? nativePageType(selector, text, options) : nativePageFill(selector, text, options);
1252
+ return datetimeEqual(kind, await loc.inputValue(), value);
1253
+ };
1254
+ // Classify through Playwright's utility-world queries when the hit test could not prove the element
1255
+ // (it is covered, or inside a frame): attributes only, nothing the page can see.
1256
+ const classifyFallback = async (loc) => {
1257
+ for (const tag of ["textarea", "select"]) {
1258
+ if (await loc.locator(`xpath=self::${tag}`).count())
1259
+ return classifyField(tag);
658
1260
  }
1261
+ if (await loc.locator("xpath=self::input").count())
1262
+ return classifyField("input", await loc.getAttribute("type"));
1263
+ const ce = await loc.getAttribute("contenteditable");
1264
+ return classifyField("div", null, ce !== null && ce.toLowerCase() !== "false");
659
1265
  };
660
- page.fill = async (selector, value, options = {}) => {
1266
+ // fill() classifies the field: text-like is typed (cleared first only when non-empty, then
1267
+ // verified), a range is dragged/clicked and nudged, a date-like editor is typed segment by segment.
1268
+ // color, unfillable types and anything the humane route cannot prove it did right go to Playwright.
1269
+ const hfill = async (selector, value, options) => {
661
1270
  try {
662
- const timeout = options.timeout ?? 30000;
663
- if (value.length > 200)
664
- return nativePageFill(selector, value, options); // bulk -> atomic
665
- if (!(await focusClick(selector, timeout)))
666
- return nativePageFill(selector, value, options);
667
- await sleep(rand(40, 120));
668
- try {
669
- await nativeKbPress(SELECT_ALL);
670
- await sleep(rand(30, 80));
671
- await nativeKbPress("Backspace");
672
- await sleep(rand(40, 120));
1271
+ if (typeof value === "string") {
1272
+ const timeout = options?.timeout; // undefined: the page's default, as Playwright would use
1273
+ const loc = page.locator(selector).first();
1274
+ const box = await boxOf(loc, timeout, Boolean(options?.force), selector);
1275
+ const info = await describe(box);
1276
+ const kind = info ? classifyField(info.tag, info.type, info.ce) : await classifyFallback(loc);
1277
+ if (!(info && (info.disabled || info.readonly))) {
1278
+ if (kind === "text" && await fillText(loc, box, value, info, selector))
1279
+ return;
1280
+ if (kind === "range" && box && await fillRange(loc, box, info, value))
1281
+ return;
1282
+ if (kind === "datetime" && box && await fillDatetime(loc, box, info, value))
1283
+ return;
1284
+ }
673
1285
  }
674
- catch { /* clear best-effort */ }
675
- await humanTypeText(value);
676
1286
  }
677
- catch {
678
- return nativePageFill(selector, value, options);
1287
+ catch (e) {
1288
+ if (isTimeout(e))
1289
+ throw e;
679
1290
  }
1291
+ // Native: color (Playwright sets it -- the OS dialog is no route), unfillable types (Playwright
1292
+ // raises, as before humanize), and anything the humane route could not prove it did right.
1293
+ return nativePageFill(selector, value, options);
680
1294
  };
681
- page.dblclick = async (selector, options = {}) => {
1295
+ page.fill = (selector, value, options = {}) => locked(() => hfill(selector, value, options));
1296
+ page.dblclick = (selector, options = {}) => locked(() => {
1297
+ const o = { ...(options ?? {}) };
1298
+ if (o.clickCount === undefined)
1299
+ o.clickCount = 2;
1300
+ return targetedClick(nativePageDblclick, selector, o, false, options ?? {});
1301
+ });
1302
+ const hpress = async (selector, key, options) => {
682
1303
  try {
683
- const timeout = options.timeout ?? 30000;
684
- const pt = await pointFor(selector, timeout);
685
- if (!pt)
686
- return nativePageDblclick(selector, options);
687
- await glide(pt.x, pt.y);
688
- await sleep(rand(40, 130));
689
- await nativeClick(pt.x, pt.y, { clickCount: 2, delay: rand(40, 90) });
1304
+ // Click into text fields only: a press on a button or a link that is not focused yet is
1305
+ // Playwright's (it focuses without activating); clicking it first activated it twice.
1306
+ await clickInto(selector, options?.timeout);
1307
+ // The persona's hold unless the caller set a real one, for the same reason keyboard.press above
1308
+ // carries one: this is the path locator.press() delegates to.
1309
+ const d = options?.delay;
1310
+ await nativeKbPress(key, { delay: typeof d === "number" && d > 0 ? d : keyDwell(persona) });
1311
+ return;
690
1312
  }
691
- catch {
692
- return nativePageDblclick(selector, options);
1313
+ catch (e) {
1314
+ if (isTimeout(e))
1315
+ throw e;
693
1316
  }
1317
+ return nativePagePress(selector, key, options);
694
1318
  };
695
- page.press = async (selector, key, options = {}) => {
1319
+ page.press = (selector, key, options = {}) => locked(() => hpress(selector, key, options));
1320
+ // ------------------------------------------------------------------------- selectOption
1321
+ // Open the drop-down with a click, move its highlight with the planned keys, commit with Enter: one
1322
+ // trusted input+change, the sequence a mouse-and-keyboard user produces. While the popup is open its
1323
+ // keys never reach the page (measured on r37: value unchanged until Enter). The opening click is the
1324
+ // full humanized click -- including the covered-point check -- at the point the hit test proved when
1325
+ // the centre is covered (`at`, a Playwright `position`), so a floating label over the select's centre
1326
+ // cannot swallow it and leave the letters to change a closed select one per key.
1327
+ const openAndPick = async (selector, plan, at) => {
1328
+ await targetedClick(nativePageClick, selector, at ? { position: at } : {}, false, null);
1329
+ await sleep(rand(220, 480)); // the list opens; the eye finds the option
1330
+ for (const ch of plan.keys) {
1331
+ await nativeKbPress(ch, { delay: keyDwell(persona) });
1332
+ await sleep(Math.max(40, 100 + gauss() * 35)); // well inside type-ahead's 1 s window
1333
+ }
1334
+ const n = plan.arrows;
1335
+ if (n) {
1336
+ await sleep(rand(120, 300));
1337
+ await arrowRun(n > 0 ? "ArrowDown" : "ArrowUp", Math.abs(n));
1338
+ }
1339
+ await sleep(rand(150, 400));
1340
+ await key("Enter");
1341
+ await sleep(rand(80, 160));
1342
+ };
1343
+ // `n` presses of an arrow key in an OPEN drop-down. A long run is HELD, the way a person crosses a
1344
+ // long list: one press, the keyboard's repeat delay, then auto-repeat at its rate -- Playwright sends
1345
+ // a key that is already down again as an autoRepeat keydown. Pressed one by one at a human ~180 ms
1346
+ // each, 370 rows took 68 s (verifier B). The open popup takes these keys, so the page sees none of
1347
+ // them, only how long the pick took.
1348
+ const arrowRun = async (k, n) => {
1349
+ if (n < 8 || !nativeKbDown || !nativeKbUp) {
1350
+ await key(k, n);
1351
+ return;
1352
+ }
1353
+ await nativeKbDown(k);
696
1354
  try {
697
- const timeout = options.timeout ?? 30000;
698
- if (!(await isFocused(selector))) {
699
- if (!(await focusClick(selector, timeout)))
700
- return nativePagePress(selector, key, options);
701
- await sleep(rand(40, 120));
1355
+ await sleep(rand(380, 560)); // the repeat delay
1356
+ const gap = rand(28, 36); // ~30 repeats a second
1357
+ for (let j = 0; j < n - 1; j++) {
1358
+ await sleep(gap);
1359
+ await nativeKbDown(k);
702
1360
  }
703
- // The persona's hold, for the same reason keyboard.press above carries one: this is the
704
- // path locator.press() delegates to, so without it every locator.press in a script emitted
705
- // a zero-length keypress while the typed text around it looked human.
706
- await nativeKbPress(key, { delay: keyDwell(persona) });
707
1361
  }
708
- catch {
709
- return nativePagePress(selector, key, options);
1362
+ finally {
1363
+ await sleep(rand(20, 60));
1364
+ await nativeKbUp(k); // never leave the key down
710
1365
  }
711
1366
  };
712
- // page.selectOption was the last input method humanize did not touch, so a script that used it —
713
- // rather than locator.selectOption, which has been patched for a while — went straight to
714
- // Playwright's implementation: assign the value, dispatch input+change from page script. That is
715
- // measurably scored: it fails 'interaction-select-change-trust' on clearcotelabs.com/audit,
716
- // because no user gesture can produce a change event with isTrusted false.
717
- //
718
- // The move is a HOVER, not a click: clicking a <select> opens the native popup, and the popup
719
- // swallows the arrow keys the trusted path steps the selection with. A person's pointer is on the
720
- // dropdown they are choosing from either way.
1367
+ const listboxRow = async (info, i) => {
1368
+ if (info.node === undefined)
1369
+ return null;
1370
+ return (await worldFor(page).callOnNode(info.node, ISO_LISTBOX_ROW, { i })) ?? null;
1371
+ };
1372
+ // The list box's state once its scroll has come to rest. A wheel notch scrolls the box SMOOTHLY over
1373
+ // a few frames, and a read taken mid-animation shows the row where it was, not where it is going:
1374
+ // the old loop then wheeled the wrong way and oscillated (verifier B: scrollTop 120 -> 240 -> 323 ->
1375
+ // 203 -> 83 -> 0, twice, with the row in view at 120, then a native, untrusted fallback). Two equal
1376
+ // reads 50 ms apart = at rest.
1377
+ const settledRow = async (info, i) => {
1378
+ let prev = await listboxRow(info, i);
1379
+ for (let k = 0; k < 30; k++) {
1380
+ await sleep(50);
1381
+ const cur = await listboxRow(info, i);
1382
+ if (!cur || !prev)
1383
+ return cur;
1384
+ if (Math.abs(cur.st - prev.st) < 0.5 && Math.abs(cur.row[1] - prev.row[1]) < 0.5)
1385
+ return cur;
1386
+ prev = cur;
1387
+ }
1388
+ return prev;
1389
+ };
1390
+ // The visible vertical span [top, bottom] of the row, or null when too little of it shows. A row cut
1391
+ // off by the box edge is still a target a person clicks when most of it shows.
1392
+ const rowSpan = (s) => {
1393
+ const [, y, w, h] = s.row;
1394
+ if (w <= 0 || h <= 0)
1395
+ return null;
1396
+ const top = Math.max(y, s.top), bottom = Math.min(y + h, s.bottom);
1397
+ return bottom - top >= Math.min(h, Math.max(6, h * 0.6)) ? [top, bottom] : null;
1398
+ };
1399
+ // Wheel the list box until row `i` shows: the pointer goes over the box once, then as many notches
1400
+ // as the distance needs -- one at first, to learn how far a notch scrolls this box -- each burst read
1401
+ // only once the scroll has settled. {top, bottom, s}, or null when it cannot be brought into view
1402
+ // (the wheel does not move the box, or it is at its end).
1403
+ const scrollRowIntoView = async (info, i) => {
1404
+ let s = await settledRow(info, i);
1405
+ if (!s)
1406
+ return null;
1407
+ let span = rowSpan(s), per = null, over = false, notches = 0;
1408
+ while (span === null && notches < 60) {
1409
+ const [, y, , h] = s.row;
1410
+ const room = s.bottom - s.top;
1411
+ const margin = rand(2, Math.max(3, Math.min(12, (room - h) * 0.4)));
1412
+ const need = y + h > s.bottom ? (y + h) - s.bottom + margin : y - s.top - margin;
1413
+ if ((need > 0 && s.st >= s.max - 0.5) || (need < 0 && s.st <= 0.5))
1414
+ return null;
1415
+ if (!over) {
1416
+ const [bx, , bw] = s.box;
1417
+ await glide(bx + bw * rand(0.3, 0.7), s.top + room * rand(0.3, 0.7), { targetW: Math.max(20, room) });
1418
+ await sleep(rand(80, 200));
1419
+ over = true;
1420
+ }
1421
+ const n = !per ? 1
1422
+ : Math.max(1, Math.min(8, Math.floor(Math.abs(need) / per) + (Math.abs(need) % per > per * 0.25 ? 1 : 0)));
1423
+ const notch = await wheelNotchPx();
1424
+ for (let j = 0; j < n; j++) {
1425
+ await nativeWheel(0, need > 0 ? notch : -notch);
1426
+ notches++;
1427
+ if (j < n - 1)
1428
+ await sleep(rand(30, 70));
1429
+ }
1430
+ const before = s.st;
1431
+ const next = await settledRow(info, i);
1432
+ if (!next)
1433
+ return null;
1434
+ s = next;
1435
+ const moved = Math.abs(s.st - before);
1436
+ if (moved < 0.5)
1437
+ return null; // the wheel did not scroll the box
1438
+ per = moved / n;
1439
+ span = rowSpan(s);
1440
+ }
1441
+ return span ? { top: span[0], bottom: span[1], s } : null;
1442
+ };
1443
+ // A list box (multiple or size > 1): click the rows -- the first plainly (which also drops any other
1444
+ // selection), the rest with Ctrl/Cmd held, scrolling the box with the wheel to bring a row into view
1445
+ // the way a person does.
1446
+ const selectListbox = async (info, idx) => {
1447
+ const want = [...idx].sort((a, b) => a - b);
1448
+ const opts = info.options ?? [];
1449
+ const selectedNow = opts.map((o, i) => (o.selected ? i : -1)).filter((i) => i >= 0);
1450
+ if (want.length === selectedNow.length && want.every((i, k) => i === selectedNow[k])) {
1451
+ return want.map((i) => opts[i].value);
1452
+ }
1453
+ for (let k = 0; k < want.length; k++) {
1454
+ const got = await scrollRowIntoView(info, want[k]);
1455
+ if (!got)
1456
+ return null;
1457
+ const { top, bottom, s } = got;
1458
+ const r = s.row;
1459
+ const px = rand(r[0] + r[2] * 0.12, r[0] + Math.min(r[2] * 0.6, 140));
1460
+ let py = (top + bottom) / 2 + gauss() * Math.max(0.5, (bottom - top) * 0.12);
1461
+ py = Math.min(Math.max(py, top + 1), bottom - 1);
1462
+ await glide(px, py, { targetW: bottom - top });
1463
+ if (k && nativeKbDown && nativeKbUp) {
1464
+ // ControlOrMeta: Playwright sends the add-to-selection key the ENGINE honours
1465
+ await pressAt(normalizeClickOptions({ modifiers: ["ControlOrMeta"] }));
1466
+ }
1467
+ else {
1468
+ await sleep(rand(40, 130));
1469
+ await nativeClick(px, py, { delay: clickHold(persona) });
1470
+ }
1471
+ await sleep(rand(150, 350));
1472
+ }
1473
+ const final = await listboxRow(info, want[0]);
1474
+ if (!final)
1475
+ return null;
1476
+ const got = final.sel.map((v, i) => (v ? i : -1)).filter((i) => i >= 0);
1477
+ return got.length === want.length && got.every((i, k) => i === want[k]) ? want.map((i) => opts[i].value) : null;
1478
+ };
1479
+ // Pick options the way a person does (see openAndPick / selectListbox). Resolves to the selected
1480
+ // values like Playwright, or null when the humane route cannot apply or cannot be shown to have
1481
+ // worked -- the caller then runs stock selectOption.
1482
+ const selectHumanized = async (selector, values, options) => {
1483
+ const wants = selectWants(values);
1484
+ if (!wants)
1485
+ return null;
1486
+ const loc = page.locator(selector).first();
1487
+ const box = await boxOf(loc, options?.timeout, Boolean(options?.force), selector);
1488
+ const info = await describe(box, "select");
1489
+ if (!box || !info || info.tag !== "select" || info.disabled)
1490
+ return null;
1491
+ const opts = info.options ?? [];
1492
+ const idx = [];
1493
+ for (const w of wants) {
1494
+ const i = wantIndex(opts, w);
1495
+ if (i < 0 || opts[i].disabled)
1496
+ return null; // not there (yet) or disabled: Playwright waits / reports it
1497
+ if (!idx.includes(i))
1498
+ idx.push(i);
1499
+ }
1500
+ if (info.multiple || (info.size ?? 0) > 1) {
1501
+ return selectListbox(info, info.multiple ? idx : [Math.min(...idx)]);
1502
+ }
1503
+ const t = Math.min(...idx); // Playwright: the FIRST option (document order) matching any wanted one
1504
+ if (info.selectedIndex === t)
1505
+ return [opts[t].value];
1506
+ const labels = opts.map((o) => (o.disabled ? null : (o.label || "")));
1507
+ const plan = planSelect(labels, info.selectedIndex ?? -1, t);
1508
+ if (plan === null)
1509
+ return null;
1510
+ const pt = provenPoint(box, info);
1511
+ const at = pt ? { x: pt[0] - box.x - (info.bl || 0), y: pt[1] - box.y - (info.bt || 0) } : null;
1512
+ await openAndPick(selector, plan, at);
1513
+ if ((await loc.inputValue()) === opts[t].value)
1514
+ return [opts[t].value];
1515
+ // One correction from wherever the highlight landed (arrows only), then give up to native.
1516
+ const again = await redescribe(info, "select");
1517
+ if (!again)
1518
+ return null;
1519
+ const fix = planSelect(labels, again.selectedIndex ?? -1, t, 0);
1520
+ if (fix === null)
1521
+ return null;
1522
+ await openAndPick(selector, fix, at);
1523
+ return (await loc.inputValue()) === opts[t].value ? [opts[t].value] : null;
1524
+ };
1525
+ // page.selectOption, humanized: no more script-assigned value with an untrusted change event
1526
+ // (which fails 'interaction-select-change-trust' on clearcotelabs.com/audit).
721
1527
  if (nativePageSelect) {
722
- page.selectOption = async (selector, values, options = {}) => {
723
- const timeout = options.timeout ?? 30000;
1528
+ const hselect = async (selector, values, options) => {
724
1529
  try {
725
- const pt = await pointFor(selector, timeout);
726
- if (pt) {
727
- await glide(pt.x, pt.y);
728
- await sleep(rand(50, 150));
729
- }
1530
+ const res = await selectHumanized(selector, values, options ?? {});
1531
+ if (res !== null)
1532
+ return res;
730
1533
  }
731
- catch { /* the pre-move is best-effort; a trusted selection still beats a native one */ }
732
- try {
733
- const got = await trustedSelect(page, selector, values, timeout);
734
- if (got)
735
- return got;
1534
+ catch (e) {
1535
+ if (isTimeout(e))
1536
+ throw e;
736
1537
  }
737
- catch { /* fall back */ }
738
1538
  return nativePageSelect(selector, values, options);
739
1539
  };
1540
+ page.selectOption = (selector, values, options = {}) => locked(() => hselect(selector, values, options));
1541
+ }
1542
+ // ------------------------------------------------------------------ check / uncheck / setChecked
1543
+ // 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
1545
+ // click owns its native fallback, so nothing here may click a second time -- and when one click did
1546
+ // not change the state (a page that cancels it), this raises like Playwright's check instead of
1547
+ // clicking again.
1548
+ const setChecked = async (orig, selector, checked, options) => {
1549
+ const opts = options ?? {};
1550
+ if (opts.trial)
1551
+ return orig(selector, opts);
1552
+ const loc = page.locator(selector).first();
1553
+ if ((await loc.isChecked({ timeout: opts.timeout })) === checked)
1554
+ return;
1555
+ await targetedClick(nativePageClick, selector, { ...opts }, false, null);
1556
+ if ((await loc.isChecked()) === checked)
1557
+ return;
1558
+ throw new Error("Clicking the checkbox did not change its state");
1559
+ };
1560
+ for (const [name, want] of [["check", true], ["uncheck", false]]) {
1561
+ if (typeof page[name] === "function") {
1562
+ const orig = page[name].bind(page);
1563
+ page[name] = (selector, options = {}) => locked(() => setChecked(orig, selector, want, options));
1564
+ }
740
1565
  }
741
- const wrapTargeted = (name, noClick) => {
742
- const orig = page[name].bind(page);
743
- page[name] = async (selector, options = {}) => {
744
- // Actionability pre-flight before a TRUSTED humanized click: visible+enabled+stable+
745
- // not-covered, else fall back to the native click (which has its own waits) — only improves.
1566
+ if (typeof page.setChecked === "function") {
1567
+ const origSc = page.setChecked.bind(page);
1568
+ page.setChecked = (selector, checked, options = {}) => locked(() => setChecked((s, o) => origSc(s, checked, o), selector, Boolean(checked), options));
1569
+ }
1570
+ // ------------------------------------------------------------------------- click / hover
1571
+ // The covered-point check, through the browser's own hit test read in the isolated world: nothing is
1572
+ // dispatched and the pointer does not move. It replaced a Playwright TRIAL action, which moves the
1573
+ // REAL pointer onto the element and back and performs a press with its events blocked: verifier B
1574
+ // saw, before every humanized click since 0.46.0, a mousemove inside the target, a 450-500 px jump
1575
+ // back, then the glide, and a pointerdown/pointerup/click (left, even for a right click) that window
1576
+ // 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) => {
1579
+ const deadline = Date.now() + COVER_CHECK_MS;
1580
+ let hit = await hitInBox(box, x, y);
1581
+ while (hit === false && Date.now() < deadline) {
1582
+ 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;
1603
+ }
1604
+ catch {
1605
+ return false;
1606
+ }
1607
+ };
1608
+ // The humanized form of page.click / dblclick / hover (and the click inside check/uncheck and the
1609
+ // drop-down opener).
1610
+ //
1611
+ // Every Playwright option is honoured: `button`, `clickCount`, `delay` (the hold) and `modifiers`
1612
+ // 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.
1616
+ const targetedClick = async (orig, selector, options, noClick, nativeOpts) => {
1617
+ const opts = options ?? {};
1618
+ const o = normalizeClickOptions(opts);
1619
+ const native = async () => {
1620
+ if (!orig)
1621
+ throw new Error("humanized click could not run");
746
1622
  try {
747
- const timeout = options.timeout ?? 30000;
748
- const loc = page.locator(selector).first();
749
- await loc.waitFor({ state: "visible", timeout });
750
- await loc.scrollIntoViewIfNeeded({ timeout });
751
- if (!(await loc.isEnabled()))
752
- return orig(selector, options);
753
- let box = await loc.boundingBox();
754
- if (!box)
755
- return orig(selector, options);
1623
+ return await orig(selector, nativeOpts ?? opts);
1624
+ }
1625
+ finally {
1626
+ // 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.
1628
+ st.placed = true;
1629
+ }
1630
+ };
1631
+ if (o.trial)
1632
+ return native();
1633
+ try {
1634
+ const timeout = opts.timeout; // undefined: the page's default, as Playwright would use
1635
+ const loc = page.locator(selector).first();
1636
+ // Playwright skips actionability with force: no wait then -- a target it would click hidden goes
1637
+ // native at once instead of timing out first.
1638
+ const box = await boxOf(loc, timeout, o.force, selector);
1639
+ if (!box)
1640
+ throw new NativeSignal();
1641
+ if (!o.force && !(await loc.isEnabled()))
1642
+ throw new NativeSignal();
1643
+ let x, y;
1644
+ 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;
1650
+ }
1651
+ else {
1652
+ ({ x, y } = clickPoint(box, st, persona));
1653
+ }
1654
+ if (!o.force && orig) {
1655
+ // Stability, then covered-by: 50 ms on, the element must still fill `box` (an animation in
1656
+ // flight does not) and own the click point -- no trusted press at a point some overlay owns.
1657
+ // Either failing goes native: Playwright waits for it.
756
1658
  await sleep(50);
757
- const box2 = await loc.boundingBox();
758
- if (!box2 || Math.abs(box2.x - box.x) > 1 || Math.abs(box2.y - box.y) > 1) {
759
- return orig(selector, options);
760
- }
761
- box = box2;
762
- const cp = clickPoint(box, st, persona);
763
- const x = cp.x, y = cp.y;
764
- // covered-by: don't fire a trusted click at a point some overlay owns. A TRIAL action runs
765
- // Playwright's own hit test at exactly this point, in its utility world, and dispatches
766
- // nothing; the old page.evaluate(elementFromPoint) ran in the page's world, where a hooked
767
- // prototype saw it. A covered point times out fast -> the native path waits for it.
1659
+ if (!(await reachesPoint(orig, selector, box, x, y)))
1660
+ throw new NativeSignal();
1661
+ }
1662
+ if (o.modifiers.length && !(nativeKbDown && nativeKbUp))
1663
+ throw new NativeSignal();
1664
+ if (noClick && o.modifiers.length) {
1665
+ // hover with modifiers: Playwright holds them across the move
1666
+ const held = [];
768
1667
  try {
769
- await orig(selector, { trial: true, timeout: COVER_CHECK_MS, position: { x: x - box.x, y: y - box.y } });
770
- }
771
- catch {
772
- return orig(selector, options); // covered -> let PW wait for it on top
1668
+ for (const m of o.modifiers) {
1669
+ await nativeKbDown(m);
1670
+ held.push(m);
1671
+ await sleep(rand(40, 110));
1672
+ }
1673
+ await glide(x, y);
773
1674
  }
774
- await glide(x, y);
775
- if (!noClick) {
776
- await sleep(rand(40, 130));
777
- await nativeClick(x, y, { delay: clickHold(persona) });
1675
+ finally {
1676
+ for (const m of held.reverse()) {
1677
+ await sleep(rand(20, 80));
1678
+ try {
1679
+ await nativeKbUp(m);
1680
+ }
1681
+ catch { /* one failed keyup must not strand the others */ }
1682
+ }
778
1683
  }
779
1684
  return;
780
1685
  }
781
- catch {
782
- return orig(selector, options);
783
- }
784
- };
1686
+ await glide(x, y);
1687
+ if (!noClick)
1688
+ await pressAt(o);
1689
+ return;
1690
+ }
1691
+ catch (e) {
1692
+ if (isTimeout(e))
1693
+ throw e;
1694
+ }
1695
+ // outside the try, so a native call that raises is not run a second time by the handler
1696
+ return native();
785
1697
  };
786
- wrapTargeted("click", false);
787
- wrapTargeted("hover", true);
1698
+ page.click = (selector, options = {}) => locked(() => targetedClick(nativePageClick, selector, options, false, null));
1699
+ page.hover = (selector, options = {}) => locked(() => targetedClick(nativePageHover, selector, options, true, null));
788
1700
  // Marker the Locator-class patch keys off: humanize is active for THIS page.
789
1701
  page._clearcoteHumanized = true;
790
1702
  patchLocatorClass(page.locator("html"));
791
1703
  }
792
- /** Choose a <select> option with the keyboard, so the ENGINE fires the events. Returns Playwright's
793
- * `[value]` result once the selection is VERIFIED, or null when the keyboard route cannot be shown
794
- * to have worked and the caller should fall back to native.
795
- *
796
- * Playwright's selectOption assigns the value and dispatches input+change from script. Those arrive
797
- * with isTrusted false, which is the single most reliable dropdown tell there is — the engine cannot
798
- * produce an untrusted change, so a page reading the flag knows the selection was not made by a
799
- * person. A <select> that has focus and is CLOSED steps on ArrowUp/ArrowDown and the browser emits
800
- * input then change itself, trusted, exactly as it does for a mouse. The fix is not to forge better
801
- * events, it is to stop forging them.
802
- *
803
- * Returns null whenever that cannot be guaranteed: a multi- or disabled select, an option that
804
- * cannot be resolved, an ElementHandle target, or a platform where arrows open the popup instead of
805
- * stepping (macOS). selectedIndex is verified afterwards rather than assumed — a silently wrong
806
- * selection is worse than an untrusted one. */
807
- async function trustedSelect(page, selector, values, timeout) {
808
- try {
809
- const one = (v) => (Array.isArray(v) && v.length === 1 ? v[0] : v);
810
- const v = one(values);
811
- let by = null, want = null;
812
- if (v && typeof v === "object" && !v.constructor?.name?.includes("ElementHandle")) {
813
- if (v.index != null) {
814
- by = "index";
815
- want = v.index;
816
- }
817
- else if (v.label != null) {
818
- by = "label";
819
- want = v.label;
820
- }
821
- else if (v.value != null) {
822
- by = "value";
823
- want = v.value;
824
- }
825
- }
826
- else if (typeof v === "string") {
827
- by = "value";
828
- want = v;
1704
+ /** @internal exported for tests. Playwright's selectOption values -> the wanted options, or null when
1705
+ * the humane route does not apply (nothing asked, an ElementHandle, an option object naming more than
1706
+ * one field): the caller then runs stock selectOption. */
1707
+ export function selectWants(values) {
1708
+ if (values === null || values === undefined)
1709
+ return null;
1710
+ const list = Array.isArray(values) ? values : [values];
1711
+ const out = [];
1712
+ for (const x of list) {
1713
+ if (typeof x === "string") {
1714
+ out.push({ kind: "value", v: x });
1715
+ continue;
829
1716
  }
830
- if (by === null)
1717
+ if (!x || typeof x !== "object")
831
1718
  return null;
832
- // Read in an isolated world (./isolated.ts): the page never sees the option lookup.
833
- const plan = await worldFor(page).evaluate(ISO_SELECT_PLAN, { sel: selector, by, want });
834
- if (!plan)
1719
+ if (typeof x.asElement === "function" || /Handle/.test(x.constructor?.name ?? ""))
835
1720
  return null;
836
- if (plan.to === plan.from)
837
- return [plan.ret]; // already selected; forge nothing
838
- await page.focus(selector, { timeout });
839
- await sleep(rand(60, 160));
840
- const step = plan.to > plan.from ? "ArrowDown" : "ArrowUp";
841
- for (let i = 0; i < Math.abs(plan.to - plan.from); i++) {
842
- // page.keyboard.press — humanized by attachHumanize, so the hold comes with it.
843
- await page.keyboard.press(step);
844
- await sleep(rand(45, 120));
845
- }
846
- const got = await worldFor(page).evaluate(ISO_SELECTED_INDEX, selector);
847
- if (got === plan.to)
848
- return [plan.ret];
1721
+ const keys = ["value", "label", "index"].filter((k) => x[k] !== undefined && x[k] !== null);
1722
+ if (keys.length !== 1)
1723
+ return null;
1724
+ if (keys[0] === "index") {
1725
+ if (typeof x.index !== "number" || !Number.isInteger(x.index))
1726
+ return null;
1727
+ out.push({ kind: "index", v: x.index });
1728
+ }
1729
+ else if (keys[0] === "label") {
1730
+ out.push({ kind: "label", v: String(x.label) });
1731
+ }
1732
+ else {
1733
+ out.push({ kind: "valueOnly", v: String(x.value) });
1734
+ }
1735
+ }
1736
+ return out.length ? out : null;
1737
+ }
1738
+ /** @internal exported for tests. The option index a want resolves to, Playwright's way, or -1. */
1739
+ export function wantIndex(opts, w) {
1740
+ switch (w.kind) {
1741
+ case "value": return matchOption(opts, { value: w.v });
1742
+ case "label": return matchOption(opts, { label: w.v });
1743
+ case "index": return matchOption(opts, { index: w.v });
1744
+ case "valueOnly": return opts.findIndex((o) => o.value === w.v);
849
1745
  }
850
- catch { /* fall back */ }
851
- return null;
852
1746
  }
853
1747
  // --------------------------------------------------------------------------- locator patch
854
1748
  let locatorPatched = false;
1749
+ // The options each page-level humanized method understands. Everything the caller passed that a page
1750
+ // method accepts travels with it: the old fwd sent ONLY `timeout`, which turned
1751
+ // click({button: "right"}) into a left click and click({trial: true}) into a real one.
1752
+ const CLICK_OPTS = ["button", "clickCount", "delay", "force", "modifiers", "noWaitAfter", "position", "timeout", "trial"];
1753
+ const HOVER_OPTS = ["force", "modifiers", "noWaitAfter", "position", "timeout", "trial"];
1754
+ const DBL_OPTS = ["button", "delay", "force", "modifiers", "noWaitAfter", "position", "timeout", "trial"];
1755
+ const FILL_OPTS = ["force", "noWaitAfter", "timeout"];
1756
+ const TYPE_OPTS = ["timeout"];
1757
+ const PRESS_OPTS = ["delay", "noWaitAfter", "timeout"];
1758
+ const SELECT_OPTS = ["force", "noWaitAfter", "timeout"];
1759
+ const CHECK_OPTS = ["force", "noWaitAfter", "position", "timeout", "trial"];
1760
+ /** @internal exported for tests: the subset of `kw` named in `names` (null/undefined dropped). */
1761
+ export function pickOptions(kw, names) {
1762
+ const out = {};
1763
+ if (kw && typeof kw === "object")
1764
+ for (const k of names)
1765
+ if (kw[k] !== undefined && kw[k] !== null)
1766
+ out[k] = kw[k];
1767
+ return out;
1768
+ }
855
1769
  /** Patch the Locator prototype once so locator.* interactions route through the humanized page.*
856
1770
  * methods. Each is a no-op unless the locator's page has humanize active AND the locator targets
857
- * the main frame; anything else (frame locators, exotic selectors, errors) falls through to the
858
- * original Playwright behaviour. Composes if another library already patched the prototype. */
1771
+ * the main frame; anything else (frame locators, another page) uses the original Playwright method.
1772
+ * Locator actions stay STRICT: more than one match goes to the native method, which raises
1773
+ * Playwright's own strict-mode error. Once routed, errors propagate as they are (the page method
1774
+ * already ran its own native fallback, so nothing is retried). Composes if another library already
1775
+ * patched the prototype. */
859
1776
  function patchLocatorClass(sample) {
860
1777
  if (locatorPatched)
861
1778
  return;
@@ -863,134 +1780,101 @@ function patchLocatorClass(sample) {
863
1780
  const proto = Object.getPrototypeOf(sample);
864
1781
  if (!proto)
865
1782
  return;
866
- const oFill = proto.fill, oClick = proto.click, oType = proto.type, oDbl = proto.dblclick, oHover = proto.hover, oPress = proto.press, oPressSeq = proto.pressSequentially, oClear = proto.clear, oTap = proto.tap, oCheck = proto.check, oUncheck = proto.uncheck, oDragTo = proto.dragTo, oSelectOption = proto.selectOption;
1783
+ const oFill = proto.fill, oClick = proto.click, oType = proto.type, oDbl = proto.dblclick, oHover = proto.hover, oPress = proto.press, oPressSeq = proto.pressSequentially, oClear = proto.clear, oTap = proto.tap, oCheck = proto.check, oUncheck = proto.uncheck, oDragTo = proto.dragTo, oSelectOption = proto.selectOption, oSetChecked = proto.setChecked;
1784
+ // Main-frame locators only: a FrameLocator's child keeps the MAIN frame as its _frame and enters the
1785
+ // iframe through its selector, so the selector is what tells. Routed through page.fill, its
1786
+ // prefilled field was never cleared ("old" + "new" = "oldnew"); frame locators stay native.
867
1787
  const on = (self) => {
868
1788
  const pg = self.page();
869
1789
  if (!pg || !pg._clearcoteHumanized)
870
1790
  return false;
871
1791
  try {
1792
+ if (String(self._selector ?? "").includes("internal:control=enter-frame"))
1793
+ return false;
872
1794
  return self._frame === pg.mainFrame();
873
1795
  }
874
1796
  catch {
875
1797
  return false;
876
1798
  }
877
1799
  };
1800
+ // Locator actions are STRICT in Playwright: more than one match is an error. The page-level methods
1801
+ // they route through take the first match, so check the count here and let the native call raise
1802
+ // its own strict-mode error when it is not at most one.
1803
+ const strict = async (self) => {
1804
+ try {
1805
+ return (await self.count()) <= 1;
1806
+ }
1807
+ catch {
1808
+ return false;
1809
+ }
1810
+ };
878
1811
  const sel = (self) => self._selector;
879
- const fwd = (kw) => (kw && kw.timeout != null ? { timeout: kw.timeout } : {});
1812
+ // Once routed, every error propagates: the page-level wrappers own the native fallback, so retrying
1813
+ // the original locator method here ran the action a second (or third) time -- a checkbox whose click
1814
+ // is cancelled was clicked three times.
1815
+ const route = async (self, fn) => {
1816
+ if (on(self) && await strict(self))
1817
+ return [true, await fn()];
1818
+ return [false, undefined];
1819
+ };
880
1820
  proto.fill = async function (value, kw = {}) {
881
- if (on(this)) {
882
- try {
883
- return await this.page().fill(sel(this), value, fwd(kw));
884
- }
885
- catch { /* fall back */ }
886
- }
887
- return oFill.call(this, value, kw);
1821
+ const [done, r] = await route(this, () => this.page().fill(sel(this), value, pickOptions(kw, FILL_OPTS)));
1822
+ return done ? r : oFill.call(this, value, kw);
888
1823
  };
889
1824
  proto.click = async function (kw = {}) {
890
- if (on(this)) {
891
- try {
892
- return await this.page().click(sel(this), fwd(kw));
893
- }
894
- catch { /* fall back */ }
895
- }
896
- return oClick.call(this, kw);
1825
+ const [done, r] = await route(this, () => this.page().click(sel(this), pickOptions(kw, CLICK_OPTS)));
1826
+ return done ? r : oClick.call(this, kw);
897
1827
  };
898
1828
  proto.type = async function (text, kw = {}) {
899
- if (on(this)) {
900
- try {
901
- return await this.page().type(sel(this), text, fwd(kw));
902
- }
903
- catch { /* fall back */ }
904
- }
905
- return oType.call(this, text, kw);
1829
+ const [done, r] = await route(this, () => this.page().type(sel(this), text, pickOptions(kw, TYPE_OPTS)));
1830
+ return done ? r : oType.call(this, text, kw);
906
1831
  };
907
1832
  proto.dblclick = async function (kw = {}) {
908
- if (on(this)) {
909
- try {
910
- return await this.page().dblclick(sel(this), fwd(kw));
911
- }
912
- catch { /* fall back */ }
913
- }
914
- return oDbl.call(this, kw);
1833
+ const [done, r] = await route(this, () => this.page().dblclick(sel(this), pickOptions(kw, DBL_OPTS)));
1834
+ return done ? r : oDbl.call(this, kw);
915
1835
  };
916
1836
  proto.hover = async function (kw = {}) {
917
- if (on(this)) {
918
- try {
919
- return await this.page().hover(sel(this), fwd(kw));
920
- }
921
- catch { /* fall back */ }
922
- }
923
- return oHover.call(this, kw);
1837
+ const [done, r] = await route(this, () => this.page().hover(sel(this), pickOptions(kw, HOVER_OPTS)));
1838
+ return done ? r : oHover.call(this, kw);
924
1839
  };
925
1840
  proto.press = async function (key, kw = {}) {
926
- if (on(this)) {
927
- try {
928
- return await this.page().press(sel(this), key, fwd(kw));
929
- }
930
- catch { /* fall back */ }
931
- }
932
- return oPress.call(this, key, kw);
1841
+ const [done, r] = await route(this, () => this.page().press(sel(this), key, pickOptions(kw, PRESS_OPTS)));
1842
+ return done ? r : oPress.call(this, key, kw);
933
1843
  };
934
1844
  proto.pressSequentially = async function (text, kw = {}) {
935
- if (on(this)) {
936
- try {
937
- return await this.page().type(sel(this), text, fwd(kw));
938
- }
939
- catch { /* fall back */ }
940
- }
941
- return oPressSeq.call(this, text, kw);
1845
+ const [done, r] = await route(this, () => this.page().type(sel(this), text, pickOptions(kw, TYPE_OPTS)));
1846
+ return done ? r : oPressSeq.call(this, text, kw);
942
1847
  };
943
1848
  proto.clear = async function (kw = {}) {
944
- if (on(this)) {
945
- try {
946
- return await this.page().fill(sel(this), "", fwd(kw));
947
- }
948
- catch { /* fall back */ }
949
- }
950
- return oClear.call(this, kw);
1849
+ const [done, r] = await route(this, () => this.page().fill(sel(this), "", pickOptions(kw, FILL_OPTS)));
1850
+ return done ? r : oClear.call(this, kw);
951
1851
  };
952
1852
  proto.tap = async function (kw = {}) {
953
- if (on(this)) {
954
- try {
955
- return await this.page().click(sel(this), fwd(kw));
956
- }
957
- catch { /* fall back */ }
958
- }
959
- return oTap.call(this, kw);
1853
+ const [done, r] = await route(this, () => this.page().click(sel(this), pickOptions(kw, HOVER_OPTS)));
1854
+ return done ? r : oTap.call(this, kw);
960
1855
  };
961
1856
  proto.check = async function (kw = {}) {
962
- if (on(this)) {
963
- try {
964
- if (!(await this.isChecked()))
965
- return await this.page().click(sel(this), fwd(kw));
966
- return;
967
- }
968
- catch { /* fall back */ }
969
- }
970
- return oCheck.call(this, kw);
1857
+ const [done, r] = await route(this, () => this.page().check(sel(this), pickOptions(kw, CHECK_OPTS)));
1858
+ return done ? r : oCheck.call(this, kw);
971
1859
  };
972
1860
  proto.uncheck = async function (kw = {}) {
973
- if (on(this)) {
974
- try {
975
- if (await this.isChecked())
976
- return await this.page().click(sel(this), fwd(kw));
977
- return;
978
- }
979
- catch { /* fall back */ }
980
- }
981
- return oUncheck.call(this, kw);
982
- };
983
- proto.selectOption = async function (values, kw = {}) {
984
- // Routed through the page method (which glides to the control first) rather than calling
985
- // trustedSelect straight, so a locator selection gets the same pre-move as a locator click.
986
- if (on(this)) {
987
- try {
988
- return await this.page().selectOption(sel(this), values, fwd(kw));
989
- }
990
- catch { /* fall back */ }
991
- }
992
- return oSelectOption.call(this, values, kw);
1861
+ const [done, r] = await route(this, () => this.page().uncheck(sel(this), pickOptions(kw, CHECK_OPTS)));
1862
+ return done ? r : oUncheck.call(this, kw);
993
1863
  };
1864
+ if (typeof oSetChecked === "function") {
1865
+ proto.setChecked = async function (checked, kw = {}) {
1866
+ const [done, r] = await route(this, () => this.page().setChecked(sel(this), checked, pickOptions(kw, CHECK_OPTS)));
1867
+ return done ? r : oSetChecked.call(this, checked, kw);
1868
+ };
1869
+ }
1870
+ // Delegate to the humanized page.selectOption, like every other method here: one implementation
1871
+ // behind the page method is what stops the two entry points drifting apart.
1872
+ if (typeof oSelectOption === "function") {
1873
+ proto.selectOption = async function (values, kw = {}) {
1874
+ const [done, r] = await route(this, () => this.page().selectOption(sel(this), values, pickOptions(kw, SELECT_OPTS)));
1875
+ return done ? r : oSelectOption.call(this, values, kw);
1876
+ };
1877
+ }
994
1878
  proto.dragTo = async function (target, kw = {}) {
995
1879
  if (on(this)) {
996
1880
  try {