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/README.md +33 -14
- package/dist/cloud.d.ts.map +1 -1
- package/dist/cloud.js +5 -0
- package/dist/cloud.js.map +1 -1
- package/dist/docker.d.ts.map +1 -1
- package/dist/docker.js +5 -1
- package/dist/docker.js.map +1 -1
- package/dist/download.d.ts +2 -0
- package/dist/download.d.ts.map +1 -1
- package/dist/download.js +2 -1
- package/dist/download.js.map +1 -1
- package/dist/forms.d.ts +198 -0
- package/dist/forms.d.ts.map +1 -0
- package/dist/forms.js +632 -0
- package/dist/forms.js.map +1 -0
- package/dist/humanize.d.ts +37 -0
- package/dist/humanize.d.ts.map +1 -1
- package/dist/humanize.js +1207 -323
- package/dist/humanize.js.map +1 -1
- package/dist/index.d.ts +19 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -12
- package/dist/index.js.map +1 -1
- package/dist/isolated.d.ts +71 -3
- package/dist/isolated.d.ts.map +1 -1
- package/dist/isolated.js +226 -26
- package/dist/isolated.js.map +1 -1
- package/dist/widevine.d.ts +90 -5
- package/dist/widevine.d.ts.map +1 -1
- package/dist/widevine.js +391 -40
- package/dist/widevine.js.map +1 -1
- package/package.json +1 -1
package/dist/humanize.js
CHANGED
|
@@ -4,13 +4,15 @@
|
|
|
4
4
|
// dragging, scrolling and typing — dispatched as native trusted input (isTrusted===true,
|
|
5
5
|
// navigator.webdriver stays false). It works whether you drive the page directly or via
|
|
6
6
|
// locators:
|
|
7
|
-
// page level — page.click / hover / dblclick / type / fill / press / selectOption
|
|
8
|
-
//
|
|
9
|
-
// page.
|
|
7
|
+
// page level — page.click / hover / dblclick / type / fill / press / selectOption /
|
|
8
|
+
// check / uncheck / setChecked,
|
|
9
|
+
// page.mouse.move / click / dblclick / wheel and held-button drags
|
|
10
|
+
// (down -> move -> up), page.keyboard.type / press
|
|
10
11
|
// locator level — locator.click / type / fill / hover / dblclick / press /
|
|
11
|
-
// pressSequentially / clear / check / uncheck /
|
|
12
|
+
// pressSequentially / clear / check / uncheck / setChecked / tap /
|
|
13
|
+
// selectOption / dragTo
|
|
12
14
|
// (routed through the humanized page methods; main-frame locators only,
|
|
13
|
-
// native fallback)
|
|
15
|
+
// STRICT like Playwright, native fallback)
|
|
14
16
|
// `showCursor: true` — injects a red cursor dot that follows the real mousemove events.
|
|
15
17
|
//
|
|
16
18
|
// Mouse moves build an eased, slightly bowed cubic-bezier path SDK-side from the tracked cursor
|
|
@@ -19,8 +21,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,
|
|
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
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
return
|
|
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
|
-
|
|
341
|
-
|
|
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
|
-
|
|
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
|
|
680
|
+
// occasional fat-finger on unshifted letters and digits, then notice + correct
|
|
497
681
|
let typo = false;
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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(
|
|
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 (
|
|
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
|
-
|
|
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 ===
|
|
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
|
-
//
|
|
614
|
-
|
|
615
|
-
const
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|
-
|
|
622
|
-
|
|
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
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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
|
-
|
|
629
|
-
|
|
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
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
|
645
|
-
|
|
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
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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
|
-
|
|
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
|
-
|
|
657
|
-
|
|
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
|
-
|
|
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
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
await
|
|
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
|
-
|
|
1313
|
+
catch (e) {
|
|
1314
|
+
if (isTimeout(e))
|
|
1315
|
+
throw e;
|
|
693
1316
|
}
|
|
1317
|
+
return nativePagePress(selector, key, options);
|
|
694
1318
|
};
|
|
695
|
-
page.press =
|
|
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
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
await
|
|
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
|
-
|
|
709
|
-
|
|
1362
|
+
finally {
|
|
1363
|
+
await sleep(rand(20, 60));
|
|
1364
|
+
await nativeKbUp(k); // never leave the key down
|
|
710
1365
|
}
|
|
711
1366
|
};
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
//
|
|
720
|
-
//
|
|
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
|
-
|
|
723
|
-
const timeout = options.timeout ?? 30000;
|
|
1528
|
+
const hselect = async (selector, values, options) => {
|
|
724
1529
|
try {
|
|
725
|
-
const
|
|
726
|
-
if (
|
|
727
|
-
|
|
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 {
|
|
732
|
-
|
|
733
|
-
|
|
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
|
-
|
|
742
|
-
const
|
|
743
|
-
page
|
|
744
|
-
|
|
745
|
-
|
|
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
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
-
|
|
782
|
-
|
|
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
|
-
|
|
787
|
-
|
|
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
|
-
/**
|
|
793
|
-
*
|
|
794
|
-
*
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
* cannot be resolved, an ElementHandle target, or a platform where arrows open the popup instead of
|
|
805
|
-
* stepping (macOS). selectedIndex is verified afterwards rather than assumed — a silently wrong
|
|
806
|
-
* selection is worse than an untrusted one. */
|
|
807
|
-
async function trustedSelect(page, selector, values, timeout) {
|
|
808
|
-
try {
|
|
809
|
-
const one = (v) => (Array.isArray(v) && v.length === 1 ? v[0] : v);
|
|
810
|
-
const v = one(values);
|
|
811
|
-
let by = null, want = null;
|
|
812
|
-
if (v && typeof v === "object" && !v.constructor?.name?.includes("ElementHandle")) {
|
|
813
|
-
if (v.index != null) {
|
|
814
|
-
by = "index";
|
|
815
|
-
want = v.index;
|
|
816
|
-
}
|
|
817
|
-
else if (v.label != null) {
|
|
818
|
-
by = "label";
|
|
819
|
-
want = v.label;
|
|
820
|
-
}
|
|
821
|
-
else if (v.value != null) {
|
|
822
|
-
by = "value";
|
|
823
|
-
want = v.value;
|
|
824
|
-
}
|
|
825
|
-
}
|
|
826
|
-
else if (typeof v === "string") {
|
|
827
|
-
by = "value";
|
|
828
|
-
want = v;
|
|
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 (
|
|
1717
|
+
if (!x || typeof x !== "object")
|
|
831
1718
|
return null;
|
|
832
|
-
|
|
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
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
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,
|
|
858
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
882
|
-
|
|
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
|
-
|
|
891
|
-
|
|
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
|
-
|
|
900
|
-
|
|
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
|
-
|
|
909
|
-
|
|
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
|
-
|
|
918
|
-
|
|
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
|
-
|
|
927
|
-
|
|
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
|
-
|
|
936
|
-
|
|
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
|
-
|
|
945
|
-
|
|
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
|
-
|
|
954
|
-
|
|
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
|
-
|
|
963
|
-
|
|
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
|
-
|
|
974
|
-
|
|
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 {
|