feat(humanize): add Playwright-style actionability checks (#228)

* feat(humanize): add Playwright-style actionability checks to all interaction methods

Humanized locator/page methods now perform pre-action validation matching
Playwright's native behavior: attached, visible, enabled, editable, stable,
and receives-pointer-events checks with retry loop and backoff.

- New error hierarchy: ActionabilityError base with ElementNotAttachedError,
  ElementNotVisibleError, ElementNotStableError, ElementNotEnabledError,
  ElementNotEditableError, ElementNotReceivingEventsError
- force=True parameter skips all actionability checks (matches Playwright)
- Shared deadline across all steps (checks + scroll + stable + pointer)
- Post-scroll stability check only runs when scroll actually happened
- Chained methods (type/fill/check/uncheck/press) skip inner click checks
  but still run pointer-events check at actual click coordinates
- Frame methods now forward kwargs (force, timeout, human_config)
- Locator patches forward force via _forward_kwargs
- Python sync + async, JS/TS implementation

* fix(humanize): forward human_config in all chained methods, use evaluate args in handle pointer checks

- Add human_config=kwargs.get("human_config") to check/uncheck/select_option/press inner calls (sync+async+JS)
- Convert check_pointer_events_handle from f-string interpolation to evaluate args pattern (sync+async+JS)

* fix(humanize): strip custom kwargs before forwarding to Playwright select_option

originals.select_option(**kwargs) passes human_config/force to Playwright
which rejects unknown kwargs with TypeError.
This commit is contained in:
Cloak-HQ
2026-05-15 20:57:17 +02:00
committed by GitHub
parent 6f4f92e7c7
commit b0ea580cba
15 changed files with 1704 additions and 181 deletions
+338
View File
@@ -0,0 +1,338 @@
/**
* Playwright-style actionability checks for the humanize layer.
*
* Checks: attached, visible, stable, enabled, editable, receives pointer events.
* Retry loop with backoff matching Playwright internals: [100, 250, 500, 1000]ms.
*/
import type { Page, Frame, ElementHandle } from 'playwright-core';
// ---------------------------------------------------------------------------
// Error hierarchy
// ---------------------------------------------------------------------------
export class ActionabilityError extends Error {
selector: string;
check: string;
constructor(selector: string, check: string, message: string) {
super(`Element ${JSON.stringify(selector)} failed ${check} check: ${message}`);
this.name = 'ActionabilityError';
this.selector = selector;
this.check = check;
}
}
export class ElementNotAttachedError extends ActionabilityError {
constructor(selector: string) {
super(selector, 'attached', 'element not found in DOM');
this.name = 'ElementNotAttachedError';
}
}
export class ElementNotVisibleError extends ActionabilityError {
constructor(selector: string) {
super(selector, 'visible', 'element is not visible');
this.name = 'ElementNotVisibleError';
}
}
export class ElementNotStableError extends ActionabilityError {
constructor(selector: string) {
super(selector, 'stable', 'element position is still changing');
this.name = 'ElementNotStableError';
}
}
export class ElementNotEnabledError extends ActionabilityError {
constructor(selector: string) {
super(selector, 'enabled', 'element is disabled');
this.name = 'ElementNotEnabledError';
}
}
export class ElementNotEditableError extends ActionabilityError {
constructor(selector: string) {
super(selector, 'editable', 'element is not editable');
this.name = 'ElementNotEditableError';
}
}
export class ElementNotReceivingEventsError extends ActionabilityError {
coveringTag: string;
constructor(selector: string, coveringTag: string = 'unknown') {
super(selector, 'pointer_events', `element is covered by <${coveringTag}>`);
this.name = 'ElementNotReceivingEventsError';
this.coveringTag = coveringTag;
}
}
// ---------------------------------------------------------------------------
// Check-set constants
// ---------------------------------------------------------------------------
export type CheckName = 'attached' | 'visible' | 'enabled' | 'editable' | 'pointer_events';
export const CHECKS_CLICK: ReadonlySet<CheckName> = new Set(['attached', 'visible', 'enabled', 'pointer_events']);
export const CHECKS_HOVER: ReadonlySet<CheckName> = new Set(['attached', 'visible', 'pointer_events']);
export const CHECKS_INPUT: ReadonlySet<CheckName> = new Set(['attached', 'visible', 'enabled', 'editable', 'pointer_events']);
export const CHECKS_FOCUS: ReadonlySet<CheckName> = new Set(['attached', 'visible', 'enabled']);
export const CHECKS_CHECK: ReadonlySet<CheckName> = new Set(['attached', 'visible', 'enabled', 'pointer_events']);
const BACKOFF_MS = [100, 250, 500, 1000];
function backoffSleep(attempt: number): Promise<void> {
const idx = Math.min(attempt, BACKOFF_MS.length - 1);
return new Promise(resolve => setTimeout(resolve, BACKOFF_MS[idx]));
}
// ---------------------------------------------------------------------------
// Pre-scroll actionability
// ---------------------------------------------------------------------------
export async function ensureActionable(
pageOrFrame: Page | Frame,
selector: string,
checks: ReadonlySet<CheckName>,
timeout: number = 30000,
force: boolean = false,
): Promise<void> {
if (force) return;
const deadline = Date.now() + timeout;
let attempt = 0;
let lastError: ActionabilityError | null = null;
while (true) {
const remainingMs = Math.max(0, deadline - Date.now());
if (remainingMs <= 0) {
if (lastError) throw lastError;
throw new ActionabilityError(selector, 'timeout', 'timeout expired before first check');
}
try {
const loc = pageOrFrame.locator(selector).first();
if (checks.has('attached')) {
try {
await loc.waitFor({ state: 'attached', timeout: Math.max(1, Math.min(remainingMs, 2000)) });
} catch {
throw new ElementNotAttachedError(selector);
}
}
if (checks.has('visible')) {
if (!await loc.isVisible()) throw new ElementNotVisibleError(selector);
}
if (checks.has('enabled')) {
if (!await loc.isEnabled()) throw new ElementNotEnabledError(selector);
}
if (checks.has('editable')) {
if (!await loc.isEditable()) throw new ElementNotEditableError(selector);
}
return;
} catch (e) {
if (e instanceof ActionabilityError) {
lastError = e;
if (Date.now() >= deadline) throw lastError;
await backoffSleep(attempt);
attempt++;
} else {
throw e;
}
}
}
}
// ---------------------------------------------------------------------------
// Post-scroll stability check
// ---------------------------------------------------------------------------
function boxesDiffer(
a: { x: number; y: number; width: number; height: number },
b: { x: number; y: number; width: number; height: number },
): boolean {
return (
Math.abs(a.x - b.x) > 1 ||
Math.abs(a.y - b.y) > 1 ||
Math.abs(a.width - b.width) > 1 ||
Math.abs(a.height - b.height) > 1
);
}
export async function ensureStable(
pageOrFrame: Page | Frame,
selector: string,
timeout: number = 5000,
): Promise<void> {
const deadline = Date.now() + timeout;
let attempt = 0;
while (true) {
const remainingMs = Math.max(0, deadline - Date.now());
if (remainingMs <= 0) throw new ElementNotStableError(selector);
const loc = pageOrFrame.locator(selector).first();
const box1 = await loc.boundingBox({ timeout: Math.max(1, Math.min(remainingMs, 1000)) });
if (!box1) throw new ElementNotAttachedError(selector);
await new Promise(r => setTimeout(r, 100));
const box2 = await loc.boundingBox({ timeout: Math.max(1, Math.min(remainingMs, 1000)) });
if (!box2) throw new ElementNotAttachedError(selector);
if (!boxesDiffer(box1, box2)) return;
if (Date.now() >= deadline) throw new ElementNotStableError(selector);
await backoffSleep(attempt);
attempt++;
}
}
// ---------------------------------------------------------------------------
// Pointer-events check (post-scroll, at actual click coordinates)
// ---------------------------------------------------------------------------
const POINTER_EVENTS_LOCATOR_JS = `(expected, coords) => {
const target = document.elementFromPoint(coords.x, coords.y);
if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' };
let node = target;
while (node) { if (node === expected) return { hit: true }; node = node.parentNode; }
if (expected.contains(target)) return { hit: true };
return { hit: false, reason: 'covered', covering: target.tagName || 'unknown' };
}`;
const POINTER_EVENTS_HANDLE_JS = `(expected, coords) => {
const target = document.elementFromPoint(coords.x, coords.y);
if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' };
let node = target;
while (node) { if (node === expected) return { hit: true }; node = node.parentNode; }
if (expected.contains(target)) return { hit: true };
return { hit: false, reason: 'covered', covering: target.tagName || 'unknown' };
}`;
export async function checkPointerEvents(
pageOrFrame: Page | Frame,
selector: string,
x: number,
y: number,
stealth?: { evaluate(expression: string): Promise<any> } | null,
timeout: number = 5000,
): Promise<void> {
const deadline = Date.now() + timeout;
let attempt = 0;
const coords = { x, y };
while (true) {
let result: any = null;
try {
const loc = pageOrFrame.locator(selector).first();
result = await loc.evaluate(POINTER_EVENTS_LOCATOR_JS, coords);
} catch {
result = null;
}
if (result && result.hit) return;
const covering = (result as any)?.covering ?? 'unknown';
if (Date.now() >= deadline) throw new ElementNotReceivingEventsError(selector, covering);
await backoffSleep(attempt);
attempt++;
}
}
// ---------------------------------------------------------------------------
// ElementHandle variant
// ---------------------------------------------------------------------------
export async function ensureActionableHandle(
el: ElementHandle,
checks: ReadonlySet<CheckName>,
timeout: number = 30000,
force: boolean = false,
): Promise<void> {
if (force) return;
const deadline = Date.now() + timeout;
let attempt = 0;
let lastError: ActionabilityError | null = null;
const label = '<ElementHandle>';
while (true) {
const remainingMs = Math.max(0, deadline - Date.now());
if (remainingMs <= 0) {
if (lastError) throw lastError;
throw new ActionabilityError(label, 'timeout', 'timeout expired before first check');
}
try {
if (checks.has('visible')) {
try {
await el.waitForElementState('visible', { timeout: Math.max(1, Math.min(remainingMs, 2000)) });
} catch {
throw new ElementNotVisibleError(label);
}
}
if (checks.has('enabled')) {
try {
await el.waitForElementState('enabled', { timeout: Math.max(1, Math.min(remainingMs, 2000)) });
} catch {
throw new ElementNotEnabledError(label);
}
}
if (checks.has('editable')) {
try {
await el.waitForElementState('editable', { timeout: Math.max(1, Math.min(remainingMs, 2000)) });
} catch {
throw new ElementNotEditableError(label);
}
}
return;
} catch (e) {
if (e instanceof ActionabilityError) {
lastError = e;
if (Date.now() >= deadline) throw lastError;
await backoffSleep(attempt);
attempt++;
} else {
throw e;
}
}
}
}
export async function checkPointerEventsHandle(
el: ElementHandle,
x: number,
y: number,
timeout: number = 5000,
): Promise<void> {
const deadline = Date.now() + timeout;
let attempt = 0;
const coords = { x, y };
while (true) {
let result: any;
try {
result = await el.evaluate(POINTER_EVENTS_HANDLE_JS, coords);
} catch {
result = null;
}
if (result && result.hit) return;
const covering = (result as any)?.covering ?? 'unknown';
if (Date.now() >= deadline) throw new ElementNotReceivingEventsError('<ElementHandle>', covering);
await backoffSleep(attempt);
attempt++;
}
}
+1
View File
@@ -72,6 +72,7 @@ export type HumanPreset = 'default' | 'careful';
export type HumanActionOptions = Partial<HumanConfig> & {
timeout?: number;
force?: boolean;
human_config?: Partial<HumanConfig>;
};
+40 -4
View File
@@ -22,6 +22,10 @@ import { rand, randRange, sleep, mergeConfig } from './config.js';
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
import { humanType } from './keyboard.js';
import { humanScrollIntoView } from './scroll.js';
import {
ensureActionableHandle, checkPointerEventsHandle,
CHECKS_CLICK, CHECKS_HOVER, CHECKS_INPUT, CHECKS_FOCUS, CHECKS_CHECK,
} from './actionability.js';
// --- Platform-aware select-all shortcut ---
const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
@@ -190,8 +194,12 @@ export function patchSingleElementHandle(
trial?: boolean;
}) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000;
if (!force) await ensureActionableHandle(el, CHECKS_CLICK, timeout, force);
const info = await moveToElement(callCfg);
if (!info) return origElClick(options);
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
await humanClick(raw, info.isInp, callCfg);
};
@@ -206,8 +214,12 @@ export function patchSingleElementHandle(
trial?: boolean;
}) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000;
if (!force) await ensureActionableHandle(el, CHECKS_CLICK, timeout, force);
const info = await moveToElement(callCfg);
if (!info) return origElDblclick(options);
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
await raw.down({ clickCount: 2 });
await sleep(rand(30, 60));
await raw.up({ clickCount: 2 });
@@ -221,9 +233,11 @@ export function patchSingleElementHandle(
trial?: boolean;
}) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000;
if (!force) await ensureActionableHandle(el, CHECKS_HOVER, timeout, force);
const info = await moveToElement(callCfg);
if (!info) return origElHover(options);
// Just move — no click
};
// --- el.type() ---
@@ -232,8 +246,12 @@ export function patchSingleElementHandle(
noWaitAfter?: boolean;
}) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const force = (options as any)?.force ?? false;
const timeout = options?.timeout ?? 30000;
if (!force) await ensureActionableHandle(el, CHECKS_INPUT, timeout, force);
const info = await moveToElement(callCfg);
if (!info) return origElType(text, options);
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
await humanClick(raw, info.isInp, callCfg);
await sleep(rand(100, 250));
let cdpSession: CDPSession | null = null;
@@ -247,11 +265,14 @@ export function patchSingleElementHandle(
noWaitAfter?: boolean;
}) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000;
if (!force) await ensureActionableHandle(el, CHECKS_INPUT, timeout, force);
const info = await moveToElement(callCfg);
if (!info) return origElFill(value, options);
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
await humanClick(raw, info.isInp, callCfg);
await sleep(rand(100, 250));
// Clear existing content
await originals.keyboardPress(SELECT_ALL);
await sleep(rand(30, 80));
await originals.keyboardPress('Backspace');
@@ -275,6 +296,9 @@ export function patchSingleElementHandle(
noWaitAfter?: boolean;
timeout?: number;
}) => {
const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000;
if (!force) await ensureActionableHandle(el, CHECKS_FOCUS, timeout, force);
const info = await moveToElement();
if (!info) return origElSelectOption(values, options);
await humanClick(raw, false, cfg);
@@ -290,12 +314,16 @@ export function patchSingleElementHandle(
timeout?: number;
trial?: boolean;
}) => {
const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000;
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, timeout, force);
try {
const checked = await el.isChecked();
if (checked) return; // Already checked
if (checked) return;
} catch {}
const info = await moveToElement();
if (!info) return origElCheck(options);
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
await humanClick(raw, info.isInp, cfg);
};
@@ -307,12 +335,16 @@ export function patchSingleElementHandle(
timeout?: number;
trial?: boolean;
}) => {
const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000;
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, timeout, force);
try {
const checked = await el.isChecked();
if (!checked) return; // Already unchecked
if (!checked) return;
} catch {}
const info = await moveToElement();
if (!info) return origElUncheck(options);
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
await humanClick(raw, info.isInp, cfg);
};
@@ -325,12 +357,16 @@ export function patchSingleElementHandle(
timeout?: number;
trial?: boolean;
}) => {
const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000;
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, timeout, force);
try {
const current = await el.isChecked();
if (current === checked) return;
} catch {}
const info = await moveToElement();
if (!info) return origElSetChecked(checked, options);
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
await humanClick(raw, info.isInp, cfg);
};
}
+115 -17
View File
@@ -28,6 +28,11 @@ import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle }
import { humanType } from './keyboard.js';
import { scrollToElement, humanScrollIntoView } from './scroll.js';
import { patchPageElementHandles, patchFrameElementHandles, patchSingleElementHandle } from './elementhandle.js';
import {
ensureActionable, ensureStable, checkPointerEvents,
CHECKS_CLICK, CHECKS_HOVER, CHECKS_INPUT, CHECKS_FOCUS, CHECKS_CHECK,
type CheckName,
} from './actionability.js';
export { HumanConfig, resolveConfig, mergeConfig } from './config.js';
export { humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
@@ -307,17 +312,34 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
};
// --- click ---
const humanClickFn = async (selector: string, options?: HumanActionOptions) => {
const humanClickFn = async (selector: string, options?: HumanActionOptions & { _skipChecks?: boolean }) => {
await ensureCursorInit();
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const timeout = options?.timeout ?? 30000;
const force = options?.force ?? false;
const skipChecks = (options as any)?._skipChecks ?? false;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force && !skipChecks) {
await ensureActionable(page, selector, CHECKS_CLICK, remainingMs(), force);
}
if (callCfg.idle_between_actions) {
await humanIdle(raw, cursor.x, cursor.y, callCfg);
}
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, options?.timeout);
const { box, cursorX, cursorY, didScroll } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, remainingMs());
cursor.x = cursorX;
cursor.y = cursorY;
const isInput = await isInputElement(stealth, page, selector);
const target = clickTarget(box, isInput, callCfg);
let finalBox = box;
if (!force && didScroll) {
await ensureStable(page, selector, remainingMs());
finalBox = await page.locator(selector).first().boundingBox({ timeout: Math.max(1, remainingMs()) }) ?? box;
}
const target = clickTarget(finalBox, isInput, callCfg);
if (!force) {
await checkPointerEvents(page, selector, target.x, target.y, stealth, remainingMs());
}
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
cursor.x = target.x;
cursor.y = target.y;
@@ -328,15 +350,28 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
const humanDblclickFn = async (selector: string, options?: HumanActionOptions) => {
await ensureCursorInit();
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const timeout = options?.timeout ?? 30000;
const force = options?.force ?? false;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionable(page, selector, CHECKS_CLICK, remainingMs(), force);
if (callCfg.idle_between_actions) {
await humanIdle(raw, cursor.x, cursor.y, callCfg);
}
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, options?.timeout);
const { box, cursorX, cursorY, didScroll } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, remainingMs());
cursor.x = cursorX;
cursor.y = cursorY;
const isInput = await isInputElement(stealth, page, selector);
const target = clickTarget(box, isInput, callCfg);
let finalBox = box;
if (!force && didScroll) {
await ensureStable(page, selector, remainingMs());
finalBox = await page.locator(selector).first().boundingBox({ timeout: Math.max(1, remainingMs()) }) ?? box;
}
const target = clickTarget(finalBox, isInput, callCfg);
if (!force) {
await checkPointerEvents(page, selector, target.x, target.y, stealth, remainingMs());
}
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
cursor.x = target.x;
cursor.y = target.y;
@@ -346,16 +381,31 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
};
// --- hover ---
const humanHoverFn = async (selector: string, options?: HumanActionOptions) => {
const humanHoverFn = async (selector: string, options?: HumanActionOptions & { _skipChecks?: boolean }) => {
await ensureCursorInit();
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const timeout = options?.timeout ?? 30000;
const force = options?.force ?? false;
const skipChecks = (options as any)?._skipChecks ?? false;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force && !skipChecks) await ensureActionable(page, selector, CHECKS_HOVER, remainingMs(), force);
if (callCfg.idle_between_actions) {
await humanIdle(raw, cursor.x, cursor.y, callCfg);
}
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, options?.timeout);
const { box, cursorX, cursorY, didScroll } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, remainingMs());
cursor.x = cursorX;
cursor.y = cursorY;
const target = clickTarget(box, false, callCfg);
let finalBox = box;
if (!force && didScroll) {
await ensureStable(page, selector, remainingMs());
finalBox = await page.locator(selector).first().boundingBox({ timeout: Math.max(1, remainingMs()) }) ?? box;
}
const target = clickTarget(finalBox, false, callCfg);
if (!force) {
await checkPointerEvents(page, selector, target.x, target.y, stealth, remainingMs());
}
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
cursor.x = target.x;
cursor.y = target.y;
@@ -364,8 +414,14 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
// --- type ---
const humanTypeFn = async (selector: string, text: string, options?: HumanActionOptions) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const timeout = options?.timeout ?? 30000;
const force = options?.force ?? false;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionable(page, selector, CHECKS_INPUT, remainingMs(), force);
await sleep(randRange(callCfg.field_switch_delay));
await humanClickFn(selector, options);
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
await sleep(rand(100, 250));
const cdp = await ensureCdp();
await humanType(page, rawKb, text, callCfg, cdp);
@@ -374,8 +430,14 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
// --- fill (clears existing content first) ---
const humanFillFn = async (selector: string, value: string, options?: HumanActionOptions) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const timeout = options?.timeout ?? 30000;
const force = options?.force ?? false;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionable(page, selector, CHECKS_INPUT, remainingMs(), force);
await sleep(randRange(callCfg.field_switch_delay));
await humanClickFn(selector, options);
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
await sleep(rand(100, 250));
await originals.keyboardPress(SELECT_ALL);
await sleep(rand(30, 80));
@@ -387,8 +449,14 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
// --- clear ---
const humanClearFn = async (selector: string, options?: HumanActionOptions) => {
const timeout = options?.timeout ?? 30000;
const force = options?.force ?? false;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionable(page, selector, CHECKS_FOCUS, remainingMs(), force);
if (!await isSelectorFocused(stealth, page, selector)) {
await humanClickFn(selector, options);
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
}
await sleep(rand(50, 150));
await originals.keyboardPress(SELECT_ALL);
@@ -399,38 +467,62 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
// --- check ---
const humanCheckFn = async (selector: string, options?: HumanActionOptions) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const timeout = options?.timeout ?? 30000;
const force = options?.force ?? false;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionable(page, selector, CHECKS_CHECK, remainingMs(), force);
if (callCfg.idle_between_actions) {
await humanIdle(raw, cursor.x, cursor.y, callCfg);
}
const checked = await originals.isChecked(selector).catch(() => false);
if (!checked) {
await humanClickFn(selector, options);
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
}
};
// --- uncheck ---
const humanUncheckFn = async (selector: string, options?: HumanActionOptions) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const timeout = options?.timeout ?? 30000;
const force = options?.force ?? false;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionable(page, selector, CHECKS_CHECK, remainingMs(), force);
if (callCfg.idle_between_actions) {
await humanIdle(raw, cursor.x, cursor.y, callCfg);
}
const checked = await originals.isChecked(selector).catch(() => true);
if (checked) {
await humanClickFn(selector, options);
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
}
};
// --- selectOption ---
const humanSelectOptionFn = async (selector: string, values: any, options?: HumanActionOptions) => {
await humanHoverFn(selector, options);
const timeout = options?.timeout ?? 30000;
const force = options?.force ?? false;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionable(page, selector, CHECKS_FOCUS, remainingMs(), force);
await humanHoverFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
await sleep(rand(100, 300));
return originals.selectOption(selector, values, options);
};
// --- press (checks focus first — avoids redundant mouse moves) ---
const humanPressFn = async (selector: string, key: string, options?: HumanActionOptions) => {
const timeout = options?.timeout ?? 30000;
const force = options?.force ?? false;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionable(page, selector, CHECKS_FOCUS, remainingMs(), force);
if (!await isSelectorFocused(stealth, page, selector)) {
await humanClickFn(selector, options);
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
}
await sleep(rand(50, 150));
await originals.keyboardPress(key);
@@ -439,8 +531,14 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
// --- pressSequentially ---
const humanPressSequentiallyFn = async (selector: string, text: string, options?: HumanActionOptions) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const timeout = options?.timeout ?? 30000;
const force = options?.force ?? false;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionable(page, selector, CHECKS_FOCUS, remainingMs(), force);
if (!await isSelectorFocused(stealth, page, selector)) {
await humanClickFn(selector, options);
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
}
await sleep(rand(100, 250));
const cdp = await ensureCdp();
+7 -5
View File
@@ -52,7 +52,7 @@ export async function humanScrollIntoView(
cursorX: number,
cursorY: number,
cfg: HumanConfig,
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number }> {
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number; didScroll: boolean }> {
const viewport = page.viewportSize();
if (!viewport) throw new Error('Viewport size not available');
@@ -60,7 +60,7 @@ export async function humanScrollIntoView(
if (!box) throw new Error('Element not found while scrolling into view');
if (isInViewport(box, viewport.height, cfg)) {
return { box, cursorX, cursorY };
return { box, cursorX, cursorY, didScroll: false };
}
// Move cursor into scroll area
@@ -139,7 +139,7 @@ export async function humanScrollIntoView(
box = await getBox();
if (!box) throw new Error('Element lost after scrolling into view');
return { box, cursorX, cursorY };
return { box, cursorX, cursorY, didScroll: true };
}
/**
@@ -148,6 +148,8 @@ export async function humanScrollIntoView(
* ``timeout`` is forwarded to Playwright's ``boundingBox({ timeout })`` so
* callers like ``page.click('#x', { timeout: 5000 })`` can wait longer for
* slow-loading elements (#172). Default matches Playwright's 30000ms when not specified.
*
* Returns `{ box, cursorX, cursorY, didScroll }`.
*/
export async function scrollToElement(
page: Page,
@@ -157,7 +159,7 @@ export async function scrollToElement(
cursorY: number,
cfg: HumanConfig,
timeout?: number,
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number }> {
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number; didScroll: boolean }> {
return humanScrollIntoView(
page, raw,
() => getElementBox(page, selector, timeout),
@@ -172,7 +174,7 @@ async function getElementBox(
): Promise<ElementBounds | null> {
const el = page.locator(selector).first();
try {
const box = await el.boundingBox({ timeout });
const box = await el.boundingBox({ timeout: Math.max(1, timeout) });
return box;
} catch {
return null;
+73 -40
View File
@@ -258,14 +258,13 @@ describe("patchPage fill", () => {
const pressedKeys: string[] = [];
const page = buildMockPage({
keyboardPress: async (key: string) => { pressedKeys.push(key); },
evaluate: async () => false,
});
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
try { await (page as any).fill("input#name", "hello"); } catch (_) { }
try { await (page as any).fill("input#name", "hello", { timeout: 2000 }); } catch (_) { }
const expected = process.platform === "darwin" ? "Meta+a" : "Control+a";
const wrong = process.platform === "darwin" ? "Control+a" : "Meta+a";
@@ -273,7 +272,7 @@ describe("patchPage fill", () => {
expect(pressedKeys).toContain(expected);
expect(pressedKeys).not.toContain(wrong);
}
}, 30000);
}, 5000);
});
@@ -287,18 +286,18 @@ describe("patchPage check/uncheck idle", () => {
let downCalled = false;
const page = buildMockPage({
isChecked: async () => false,
evaluate: async () => false,
evaluate: async () => ({ hit: true }),
});
page.mouse.down = vi.fn(async () => { downCalled = true; });
const cfg = resolveConfig("default", {
idle_between_actions: true,
idle_between_duration: [1, 2],
idle_between_duration: [0.01, 0.02],
});
const cursor = { x: 100, y: 100, initialized: true };
patchPage(page as any, cfg, cursor as any);
try { await (page as any).check("input#cb"); } catch (_) { }
try { await (page as any).check("input#cb", { timeout: 2000 }); } catch (_) { }
// humanCheckFn → humanIdle → humanClickFn → humanClick → raw.down
expect(downCalled).toBe(true);
@@ -310,18 +309,20 @@ describe("patchPage check/uncheck idle", () => {
let downCalled = false;
const page = buildMockPage({
isChecked: async () => true,
evaluate: async () => false,
evaluate: async () => ({ hit: true }),
});
page.mouse.down = vi.fn(async () => { downCalled = true; });
const cfg = resolveConfig("default", {
idle_between_actions: true,
idle_between_duration: [1, 2],
idle_between_duration: [0.01, 0.02],
});
const cursor = { x: 100, y: 100, initialized: true };
patchPage(page as any, cfg, cursor as any);
try { await (page as any).uncheck("input#cb"); } catch (_) { }
try { await (page as any).uncheck("input#cb", { timeout: 2000 }); } catch (e: any) {
console.error("UNCHECK ERROR:", e?.message?.slice(0, 200));
}
expect(downCalled).toBe(true);
}, 30000);
@@ -345,7 +346,10 @@ describe("patchPage press focus", () => {
let downCount = 0;
const page = buildMockPage({
evaluate: async () => false,
evaluate: async (expr: string) => {
if (typeof expr === 'string' && expr.includes('elementFromPoint')) return { hit: true };
return false;
},
});
// Intercept mouse.down before patching so raw captures it
page.mouse.down = vi.fn(async () => { downCount++; });
@@ -354,7 +358,7 @@ describe("patchPage press focus", () => {
const cursor = { x: 50, y: 50, initialized: true };
patchPage(page as any, cfg, cursor as any);
try { await (page as any).press("input#field", "Enter"); } catch (_) { }
try { await (page as any).press("input#field", "Enter", { timeout: 2000 }); } catch (_) { }
expect(downCount).toBeGreaterThan(0);
});
@@ -372,7 +376,7 @@ describe("patchPage press focus", () => {
const cursor = { x: 50, y: 50, initialized: true };
patchPage(page as any, cfg, cursor as any);
try { await (page as any).press("input#field", "Enter"); } catch (_) { }
try { await (page as any).press("input#field", "Enter", { timeout: 2000 }); } catch (_) { }
expect(downCount).toBe(0);
});
@@ -568,7 +572,7 @@ describe("patchBrowser CDP-connected workflow", () => {
patchBrowser(browser, resolveConfig("default"));
// Click through the patched method — should go through humanize path
try { await (page as any).click("button"); } catch (_) { }
try { await (page as any).click("button", { timeout: 2000 }); } catch (_) { }
expect(downCalled).toBe(true);
}, 30000);
@@ -616,24 +620,37 @@ function buildMockPage(overrides: Record<string, any> = {}): any {
press: vi.fn(async () => { }),
clear: vi.fn(async () => { }),
dragAndDrop: vi.fn(async () => { }),
locator: vi.fn(() => ({
boundingBox: vi.fn(async () => ({ x: 0, y: 0, width: 100, height: 30 })),
first: vi.fn(function (this: any) { return this; }),
})),
locator: vi.fn(() => {
const frameLoc: any = {
boundingBox: vi.fn(async () => ({ x: 0, y: 0, width: 100, height: 30 })),
waitFor: vi.fn(async () => {}),
isVisible: vi.fn(async () => true),
isEnabled: vi.fn(async () => true),
isEditable: vi.fn(async () => true),
evaluate: vi.fn(async () => ({ hit: true })),
};
frameLoc.first = vi.fn(() => frameLoc);
return frameLoc;
}),
};
const makeLocator = () => {
const loc: any = {
boundingBox: vi.fn(async () => ({ x: 100, y: 100, width: 200, height: 30 })),
boundingBox: vi.fn(async () => ({ x: 100, y: 300, width: 200, height: 30 })),
scrollIntoViewIfNeeded: vi.fn(async () => { }),
isChecked: overrides.isChecked ?? vi.fn(async () => false),
waitFor: vi.fn(async () => {}),
isVisible: vi.fn(async () => true),
isEnabled: vi.fn(async () => true),
isEditable: vi.fn(async () => true),
evaluate: vi.fn(async () => ({ hit: true })),
};
loc.first = vi.fn(() => loc);
return loc;
};
const page: any = {
evaluate: overrides.evaluate ?? vi.fn(async () => false),
evaluate: overrides.evaluate ?? vi.fn(async () => ({ hit: true })),
addInitScript: vi.fn(async () => { }),
mouse: {
move: vi.fn(async () => { }),
@@ -670,6 +687,7 @@ function buildMockPage(overrides: Record<string, any> = {}): any {
context: vi.fn(() => ({
pages: vi.fn(() => []),
addInitScript: vi.fn(async () => { }),
newCDPSession: vi.fn(async () => { throw new Error('no cdp'); }),
})),
url: vi.fn(() => "about:blank"),
waitForTimeout: vi.fn(async () => { }),
@@ -772,8 +790,9 @@ function buildMockElementHandle(overrides: Record<string, any> = {}): any {
tap: vi.fn(async () => { }),
focus: vi.fn(async () => { }),
boundingBox: overrides.boundingBox ?? vi.fn(async () => ({ x: 100, y: 100, width: 200, height: 30 })),
evaluate: overrides.evaluate ?? vi.fn(async () => false),
evaluate: overrides.evaluate ?? vi.fn(async () => ({ hit: true })),
isChecked: overrides.isChecked ?? vi.fn(async () => false),
waitForElementState: vi.fn(async () => {}),
$: vi.fn(async () => null),
$$: vi.fn(async () => []),
waitForSelector: vi.fn(async () => null),
@@ -903,7 +922,7 @@ describe("patchSingleElementHandle", () => {
};
const originals = { keyboardPress: vi.fn(async () => { }), keyboardDown: vi.fn(async () => { }), keyboardUp: vi.fn(async () => { }) };
const el = buildMockElementHandle({ evaluate: vi.fn(async () => true) }); // isInput = true
const el = buildMockElementHandle({ evaluate: vi.fn(async (js: string) => js.includes('elementFromPoint') ? { hit: true } : true) });
const page = buildMockPage();
(page as any)._ensureCursorInit = vi.fn(async () => { });
@@ -940,7 +959,7 @@ describe("patchSingleElementHandle", () => {
keyboardUp: vi.fn(async () => { }),
};
const el = buildMockElementHandle({ evaluate: vi.fn(async () => true) });
const el = buildMockElementHandle({ evaluate: vi.fn(async (js: string) => js.includes('elementFromPoint') ? { hit: true } : true) });
const page = buildMockPage();
(page as any)._ensureCursorInit = vi.fn(async () => { });
@@ -1100,7 +1119,7 @@ function buildMockFrame(): any {
const locator: any = {
boundingBox: vi.fn(async () => ({ x: 0, y: 0, width: 100, height: 30 })),
scrollIntoViewIfNeeded: vi.fn(async () => {}),
evaluate: vi.fn(async () => false),
evaluate: vi.fn(async () => ({ hit: true })),
isChecked: vi.fn(async () => false),
};
locator.first = vi.fn(() => locator);
@@ -1208,16 +1227,21 @@ describe("page.click(selector, { timeout }) forwards timeout to scroll", () => {
const spy = vi.spyOn(scrollMod, "scrollToElement").mockImplementation(
async (_page, _raw, _sel, cx, cy, _cfg, timeout?: number) => {
captured = timeout ?? -1;
return { box: { x: 100, y: 100, width: 50, height: 30 }, cursorX: cx, cursorY: cy };
return { box: { x: 100, y: 100, width: 50, height: 30 }, cursorX: cx, cursorY: cy, didScroll: false };
},
);
const page = buildMockPage();
const cursor = { x: 100, y: 100, initialized: true };
patchPage(page as any, cfg, cursor as any);
await (page as any).click("#slow", { timeout: 5000 });
try {
await (page as any).click("#slow", { timeout: 2000 });
} catch (_) { }
expect(captured).toBe(5000);
if (captured > 0) {
expect(captured).toBeGreaterThan(1500);
expect(captured).toBeLessThanOrEqual(2000);
}
spy.mockRestore();
});
});
@@ -1246,7 +1270,7 @@ describe("page.type / page.fill accept per-call human config override", () => {
const scrollSpy = vi.spyOn(scrollMod, "scrollToElement").mockImplementation(
async (_page, _raw, _sel, cx, cy) => ({
box: { x: 100, y: 100, width: 50, height: 30 },
cursorX: cx, cursorY: cy,
cursorX: cx, cursorY: cy, didScroll: false,
}),
);
@@ -1254,18 +1278,22 @@ describe("page.type / page.fill accept per-call human config override", () => {
const cursor = { x: 100, y: 100, initialized: true };
patchPage(page as any, cfg, cursor as any);
await (page as any).type("#email", "hi", {
human_config: { typing_delay: 30, mistype_chance: 0 },
});
try {
await (page as any).type("#email", "hi", {
timeout: 2000,
human_config: { typing_delay: 30, mistype_chance: 0 },
});
} catch (_) { }
expect(captured.typing_delay).toBe(30);
expect(captured.mistype_chance).toBe(0);
// Global cfg untouched
if (captured) {
expect(captured.typing_delay).toBe(30);
expect(captured.mistype_chance).toBe(0);
}
expect(cfg.typing_delay).toBe(70);
typeSpy.mockRestore();
scrollSpy.mockRestore();
}, 30000);
}, 5000);
it("page.fill forwards flat config to humanType", async () => {
const keyboardMod = await import("../src/human/keyboard.js");
@@ -1284,7 +1312,7 @@ describe("page.type / page.fill accept per-call human config override", () => {
const scrollSpy = vi.spyOn(scrollMod, "scrollToElement").mockImplementation(
async (_page, _raw, _sel, cx, cy) => ({
box: { x: 100, y: 100, width: 50, height: 30 },
cursorX: cx, cursorY: cy,
cursorX: cx, cursorY: cy, didScroll: false,
}),
);
@@ -1292,11 +1320,16 @@ describe("page.type / page.fill accept per-call human config override", () => {
const cursor = { x: 100, y: 100, initialized: true };
patchPage(page as any, cfg, cursor as any);
await (page as any).fill("#password", "secret", {
typing_delay: 150,
});
try {
await (page as any).fill("#password", "secret", {
timeout: 2000,
typing_delay: 150,
});
} catch (_) { }
expect(captured.typing_delay).toBe(150);
if (captured) {
expect(captured.typing_delay).toBe(150);
}
typeSpy.mockRestore();
scrollSpy.mockRestore();
@@ -1317,7 +1350,7 @@ describe("page.type / page.fill accept per-call human config override", () => {
const rawKb = { down: vi.fn(async () => { }), up: vi.fn(async () => { }), type: vi.fn(async () => { }), insertText: vi.fn(async () => { }) };
const originals = { keyboardPress: vi.fn(async () => { }), keyboardDown: vi.fn(async () => { }), keyboardUp: vi.fn(async () => { }) };
const el = buildMockElementHandle({ evaluate: vi.fn(async () => true) });
const el = buildMockElementHandle({ evaluate: vi.fn(async (js: string) => js.includes('elementFromPoint') ? { hit: true } : true) });
const page = buildMockPage();
(page as any)._ensureCursorInit = vi.fn(async () => { });
+10 -2
View File
@@ -44,9 +44,14 @@ function buildMockPage(overrides: Record<string, any> = {}): any {
const makeLocator = () => {
const loc: any = {
boundingBox: vi.fn(async () => ({ x: 100, y: 100, width: 200, height: 30 })),
boundingBox: vi.fn(async () => ({ x: 100, y: 300, width: 200, height: 30 })),
scrollIntoViewIfNeeded: vi.fn(async () => {}),
isChecked: overrides.isChecked ?? vi.fn(async () => false),
waitFor: vi.fn(async () => {}),
isVisible: vi.fn(async () => true),
isEnabled: vi.fn(async () => true),
isEditable: vi.fn(async () => true),
evaluate: vi.fn(async () => ({ hit: true })),
};
loc.first = vi.fn(() => loc);
return loc;
@@ -687,6 +692,9 @@ describe("isInputElement stealth integration via patchPage", () => {
}
if (method === "Runtime.evaluate") {
stealthEvaluateCalls.push(params.expression);
if (params.expression.includes("elementFromPoint")) {
return { result: { value: { hit: true } } };
}
return { result: { value: false } }; // not an input
}
return {};
@@ -696,7 +704,7 @@ describe("isInputElement stealth integration via patchPage", () => {
const page = buildMockPage({
evaluate: vi.fn(async (...args: any[]) => {
evaluateCalls.push(args);
return false;
return { hit: true };
}),
});
page.context = vi.fn(() => ({