mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
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:
+73
-40
@@ -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 () => { });
|
||||
|
||||
|
||||
@@ -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(() => ({
|
||||
|
||||
Reference in New Issue
Block a user