Files
CloakBrowser/js/tests/stealth.puppeteer.test.ts
EternalandCloakHQ 80d9f7c14e feat(js): add types to humanized method options (#205)
Replace `any` with proper TypeScript types on all humanized method options
(Playwright, Puppeteer, ElementHandle, Frame). Support flat per-call config
overrides alongside existing `human_config` style. Internalize `humanIdle`
duration computation with backward-compatible overloads.

Co-authored-by: Eternal <chasezou09@gmail.com>
2026-05-10 23:50:01 +02:00

2148 lines
75 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Unit tests for stealth / anti-detection fixes — PUPPETEER EDITION.
*
* Covers:
* - StealthEval — CDP isolated-world lifecycle (evaluate, invalidate, retry)
* - isInputElement / isSelectorFocused — stealth DOM queries with fallback
* - typeShiftSymbol — CDP Input.dispatchKeyEvent path vs evaluate fallback
* - humanType integration — shift symbols routed via CDP
* - Navigation invalidation (goto → stealth.invalidate)
* - patchPage stealth infrastructure wiring
* - SHIFT_SYMBOL_CODES / SHIFT_SYMBOL_KEYCODES completeness
* - focus() — human-like click instead of programmatic CDP focus
* - uncheck() — fallback behavior matches Playwright (assume checked on error)
* - mouse.wheel() — smooth scroll via smoothWheel
* - mouse.dragAndDrop() — Bézier drag between coordinates
* - ElementHandle patching — click, hover, type, press, tap, focus, select
* - Frame patching — delegates to page-level humanized methods
* - Browser-level patching — newPage, createBrowserContext, targetcreated
*
* All tests are fast, mock-based, and do NOT require a browser.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { resolveConfig, rand, randRange, sleep } from "../src/human/config.js";
import { humanType } from "../src/human-puppeteer/keyboard.js";
import { humanMove, humanClick, clickTarget, humanIdle } from "../src/human/mouse.js";
// =========================================================================
// Helper: build mock page / raw objects (Puppeteer-style)
// =========================================================================
function buildMockPage(overrides: Record<string, any> = {}): any {
const mainFrameObj = overrides.mainFrameReturn ?? {
childFrames: vi.fn(() => []),
click: vi.fn(async () => {}),
hover: vi.fn(async () => {}),
type: vi.fn(async () => {}),
fill: vi.fn(async () => {}),
check: vi.fn(async () => {}),
uncheck: vi.fn(async () => {}),
select: vi.fn(async () => []),
press: vi.fn(async () => {}),
clear: vi.fn(async () => {}),
focus: vi.fn(async () => {}),
tap: vi.fn(async () => {}),
dragAndDrop: vi.fn(async () => {}),
$: vi.fn(async () => null),
$$: vi.fn(async () => []),
waitForSelector: vi.fn(async () => null),
};
const page: any = {
evaluate: overrides.evaluate ?? vi.fn(async () => false),
mouse: {
move: vi.fn(async () => {}),
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
click: vi.fn(async () => {}),
wheel: vi.fn(async () => {}),
dragAndDrop: overrides.mouseDragAndDrop ?? vi.fn(async () => {}),
},
keyboard: {
press: overrides.keyboardPress
? vi.fn(overrides.keyboardPress)
: vi.fn(async () => {}),
type: vi.fn(async () => {}),
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
sendCharacter: vi.fn(async () => {}),
},
click: vi.fn(async () => {}),
hover: vi.fn(async () => {}),
type: vi.fn(async () => {}),
fill: vi.fn(async () => {}),
check: vi.fn(async () => {}),
uncheck: vi.fn(async () => {}),
select: vi.fn(async () => []),
press: vi.fn(async () => {}),
focus: vi.fn(async () => {}),
goto: vi.fn(async () => ({})),
tap: vi.fn(async () => {}),
clear: vi.fn(async () => {}),
$: overrides.$ ?? vi.fn(async () => null),
$$: overrides.$$ ?? vi.fn(async () => []),
waitForSelector: overrides.waitForSelector ?? vi.fn(async () => null),
// Puppeteer-specific: viewport() returns object (not viewportSize())
viewport: vi.fn(() => ({ width: 1280, height: 720 })),
mainFrame: vi.fn(() => mainFrameObj),
frames: vi.fn(() => []),
// Puppeteer-specific: createCDPSession on page, not context
createCDPSession: vi.fn(async () => buildMockCDP()),
url: vi.fn(() => "about:blank"),
};
return page;
}
function buildMockCDP(overrides: Record<string, any> = {}): any {
return {
send: overrides.send ?? vi.fn(async (method: string, params?: any) => {
if (method === "Page.getFrameTree") {
return { frameTree: { frame: { id: "F1" } } };
}
if (method === "Page.createIsolatedWorld") {
return { executionContextId: 42 };
}
if (method === "Runtime.evaluate") {
return { result: { value: false } };
}
return {};
}),
};
}
function buildRawKeyboard() {
const downKeys: string[] = [];
const upKeys: string[] = [];
const insertedChars: string[] = [];
const raw = {
down: vi.fn(async (k: string) => { downKeys.push(k); }),
up: vi.fn(async (k: string) => { upKeys.push(k); }),
type: vi.fn(async () => {}),
insertText: vi.fn(async (t: string) => { insertedChars.push(t); }),
};
return { raw, downKeys, upKeys, insertedChars };
}
function buildMockElementHandle(overrides: Record<string, any> = {}): any {
return {
click: vi.fn(async () => {}),
hover: vi.fn(async () => {}),
type: vi.fn(async () => {}),
press: vi.fn(async () => {}),
tap: vi.fn(async () => {}),
focus: vi.fn(async () => {}),
select: vi.fn(async () => []),
drop: vi.fn(async () => {}),
dragAndDrop: vi.fn(async () => {}),
boundingBox: overrides.boundingBox ?? vi.fn(async () => ({ x: 100, y: 200, width: 120, height: 30 })),
evaluate: overrides.evaluate ?? vi.fn(async () => false),
$: vi.fn(async () => null),
$$: vi.fn(async () => []),
waitForSelector: vi.fn(async () => null),
};
}
function buildMockBrowser(pages: any[] = []): any {
const browser: any = {
pages: vi.fn(async () => pages),
newPage: vi.fn(async () => buildMockPage()),
createBrowserContext: vi.fn(async () => ({
newPage: vi.fn(async () => buildMockPage()),
})),
on: vi.fn(),
close: vi.fn(async () => {}),
};
return browser;
}
// =========================================================================
// SHIFT_SYMBOL_CODES / SHIFT_SYMBOL_KEYCODES completeness
// =========================================================================
describe("Puppeteer: SHIFT_SYMBOL maps completeness", () => {
it("every shift symbol has a code and keycode entry", async () => {
const cfg = resolveConfig("default", { mistype_chance: 0 });
const SHIFT_SYMBOLS = ['@', '#', '!', '$', '%', '^', '&', '*', '(', ')',
'_', '+', '{', '}', '|', ':', '"', '<', '>', '?', '~'];
for (const sym of SHIFT_SYMBOLS) {
const { raw } = buildRawKeyboard();
const page = buildMockPage();
const mockCdp = {
send: vi.fn(async () => ({})),
};
await humanType(page, raw, sym, cfg, mockCdp as any);
const cdpCalls = mockCdp.send.mock.calls;
const keyEvents = cdpCalls.filter(
(c: any[]) => c[0] === "Input.dispatchKeyEvent"
);
expect(keyEvents.length).toBe(2);
expect(page.evaluate).not.toHaveBeenCalled();
}
});
it("all shift symbol keyDown events have correct structure", async () => {
const cfg = resolveConfig("default", { mistype_chance: 0 });
const { raw } = buildRawKeyboard();
const page = buildMockPage();
const cdpCalls: Array<[string, any]> = [];
const mockCdp = {
send: vi.fn(async (method: string, params: any) => {
cdpCalls.push([method, params]);
return {};
}),
};
await humanType(page, raw, "!", cfg, mockCdp as any);
const keyDown = cdpCalls.find(
([m, p]) => m === "Input.dispatchKeyEvent" && p.type === "keyDown"
);
expect(keyDown).toBeDefined();
const params = keyDown![1];
expect(params.key).toBe("!");
expect(params.modifiers).toBe(8);
expect(typeof params.code).toBe("string");
expect(params.code.length).toBeGreaterThan(0);
expect(typeof params.windowsVirtualKeyCode).toBe("number");
expect(params.windowsVirtualKeyCode).toBeGreaterThan(0);
expect(params.text).toBe("!");
expect(params.unmodifiedText).toBe("!");
});
it("keyUp event has no text/unmodifiedText fields", async () => {
const cfg = resolveConfig("default", { mistype_chance: 0 });
const { raw } = buildRawKeyboard();
const page = buildMockPage();
const cdpCalls: Array<[string, any]> = [];
const mockCdp = {
send: vi.fn(async (method: string, params: any) => {
cdpCalls.push([method, params]);
return {};
}),
};
await humanType(page, raw, "!", cfg, mockCdp as any);
const keyUp = cdpCalls.find(
([m, p]) => m === "Input.dispatchKeyEvent" && p.type === "keyUp"
);
expect(keyUp).toBeDefined();
const params = keyUp![1];
expect(params.text).toBeUndefined();
expect(params.unmodifiedText).toBeUndefined();
});
it("digit shift symbols have correct keycodes (49-57, 48)", async () => {
const digitSymbols = ['!', '@', '#', '$', '%', '^', '&', '*', '(', ')'];
const expectedKeycodes = [49, 50, 51, 52, 53, 54, 55, 56, 57, 48];
const cfg = resolveConfig("default", { mistype_chance: 0 });
for (let i = 0; i < digitSymbols.length; i++) {
const { raw } = buildRawKeyboard();
const page = buildMockPage();
const cdpCalls: Array<[string, any]> = [];
const mockCdp = {
send: vi.fn(async (method: string, params: any) => {
cdpCalls.push([method, params]);
return {};
}),
};
await humanType(page, raw, digitSymbols[i], cfg, mockCdp as any);
const keyDown = cdpCalls.find(
([m, p]) => m === "Input.dispatchKeyEvent" && p.type === "keyDown"
);
expect(keyDown).toBeDefined();
expect(keyDown![1].windowsVirtualKeyCode).toBe(expectedKeycodes[i]);
}
});
});
// =========================================================================
// typeShiftSymbol — CDP path vs fallback (Puppeteer keyboard.ts)
// =========================================================================
describe("Puppeteer: typeShiftSymbol CDP vs fallback", () => {
it("uses CDP path when cdpSession is provided (no page.evaluate)", async () => {
const cfg = resolveConfig("default", { mistype_chance: 0 });
const { raw } = buildRawKeyboard();
const page = buildMockPage();
const mockCdp = { send: vi.fn(async () => ({})) };
await humanType(page, raw, "@", cfg, mockCdp as any);
expect(page.evaluate).not.toHaveBeenCalled();
expect(mockCdp.send).toHaveBeenCalled();
});
it("CDP path does NOT call raw.insertText for shift symbols", async () => {
const cfg = resolveConfig("default", { mistype_chance: 0 });
const { raw, insertedChars } = buildRawKeyboard();
const page = buildMockPage();
const mockCdp = { send: vi.fn(async () => ({})) };
await humanType(page, raw, "#", cfg, mockCdp as any);
expect(insertedChars.length).toBe(0);
});
it("falls back to page.evaluate when no cdpSession", async () => {
const cfg = resolveConfig("default", { mistype_chance: 0 });
const { raw, insertedChars } = buildRawKeyboard();
const page = buildMockPage();
await humanType(page, raw, "$", cfg, null);
expect(page.evaluate).toHaveBeenCalled();
expect(insertedChars).toContain("$");
});
it("fallback path calls raw.insertText before page.evaluate", async () => {
const cfg = resolveConfig("default", { mistype_chance: 0 });
const callOrder: string[] = [];
const raw = {
down: vi.fn(async () => { callOrder.push("raw.down"); }),
up: vi.fn(async () => { callOrder.push("raw.up"); }),
type: vi.fn(async () => {}),
insertText: vi.fn(async () => { callOrder.push("raw.insertText"); }),
};
const page = buildMockPage({
evaluate: vi.fn(async () => { callOrder.push("page.evaluate"); }),
});
await humanType(page, raw, "%", cfg, null);
const insertIdx = callOrder.indexOf("raw.insertText");
const evalIdx = callOrder.indexOf("page.evaluate");
expect(insertIdx).toBeLessThan(evalIdx);
});
it("Shift is held during CDP key events", async () => {
const cfg = resolveConfig("default", { mistype_chance: 0 });
const callOrder: string[] = [];
const raw = {
down: vi.fn(async (k: string) => { callOrder.push(`raw.down(${k})`); }),
up: vi.fn(async (k: string) => { callOrder.push(`raw.up(${k})`); }),
type: vi.fn(async () => {}),
insertText: vi.fn(async () => {}),
};
const page = buildMockPage();
const mockCdp = {
send: vi.fn(async (method: string, params: any) => {
callOrder.push(`cdp.${params.type || method}`);
return {};
}),
};
await humanType(page, raw, "!", cfg, mockCdp as any);
const shiftDownIdx = callOrder.indexOf("raw.down(Shift)");
const keyDownIdx = callOrder.indexOf("cdp.keyDown");
const keyUpIdx = callOrder.indexOf("cdp.keyUp");
const shiftUpIdx = callOrder.indexOf("raw.up(Shift)");
expect(shiftDownIdx).toBeLessThan(keyDownIdx);
expect(keyDownIdx).toBeLessThan(keyUpIdx);
expect(keyUpIdx).toBeLessThan(shiftUpIdx);
});
});
// =========================================================================
// humanType integration — mixed text with CDP (Puppeteer keyboard.ts)
// =========================================================================
describe("Puppeteer: humanType mixed text with CDP", () => {
it("normal chars use raw.down/up, shift symbols use CDP", async () => {
const cfg = resolveConfig("default", { mistype_chance: 0 });
const { raw, downKeys } = buildRawKeyboard();
const page = buildMockPage();
const cdpCalls: Array<[string, any]> = [];
const mockCdp = {
send: vi.fn(async (method: string, params: any) => {
cdpCalls.push([method, params]);
return {};
}),
};
await humanType(page, raw, "a!", cfg, mockCdp as any);
expect(downKeys).toContain("a");
const keyEvents = cdpCalls.filter(
([m]) => m === "Input.dispatchKeyEvent"
);
expect(keyEvents.length).toBe(2);
expect(page.evaluate).not.toHaveBeenCalled();
});
it("text without shift symbols does not call CDP", async () => {
const cfg = resolveConfig("default", { mistype_chance: 0 });
const { raw } = buildRawKeyboard();
const page = buildMockPage();
const mockCdp = { send: vi.fn(async () => ({})) };
await humanType(page, raw, "hello", cfg, mockCdp as any);
expect(mockCdp.send).not.toHaveBeenCalled();
expect(page.evaluate).not.toHaveBeenCalled();
});
it("multiple shift symbols all go through CDP", async () => {
const cfg = resolveConfig("default", { mistype_chance: 0 });
const { raw } = buildRawKeyboard();
const page = buildMockPage();
const cdpCalls: Array<[string, any]> = [];
const mockCdp = {
send: vi.fn(async (method: string, params: any) => {
cdpCalls.push([method, params]);
return {};
}),
};
await humanType(page, raw, "!@#", cfg, mockCdp as any);
expect(page.evaluate).not.toHaveBeenCalled();
const keyEvents = cdpCalls.filter(
([m]) => m === "Input.dispatchKeyEvent"
);
expect(keyEvents.length).toBe(6);
});
it("'Hello World!' — no page.evaluate leak", async () => {
const cfg = resolveConfig("default", { mistype_chance: 0 });
const { raw } = buildRawKeyboard();
const page = buildMockPage();
const mockCdp = { send: vi.fn(async () => ({})) };
await humanType(page, raw, "Hello World!", cfg, mockCdp as any);
expect(page.evaluate).not.toHaveBeenCalled();
});
it("password-like text 'SecurePass!123' uses CDP for '!'", async () => {
const cfg = resolveConfig("default", { mistype_chance: 0 });
const { raw } = buildRawKeyboard();
const page = buildMockPage();
const cdpCalls: Array<[string, any]> = [];
const mockCdp = {
send: vi.fn(async (method: string, params: any) => {
cdpCalls.push([method, params]);
return {};
}),
};
await humanType(page, raw, "SecurePass!123", cfg, mockCdp as any);
const keyEvents = cdpCalls.filter(
([m]) => m === "Input.dispatchKeyEvent"
);
expect(keyEvents.length).toBe(2);
expect(keyEvents[0][1].key).toBe("!");
expect(page.evaluate).not.toHaveBeenCalled();
});
it("CDP modifier flag is always 8 (Shift)", async () => {
const cfg = resolveConfig("default", {
mistype_chance: 0,
typing_delay: 0,
shift_down_delay: [0, 0],
shift_up_delay: [0, 0],
key_hold: [0, 0],
});
const allSymbols = '@#!$%^&*()_+{}|:"<>?~';
const { raw } = buildRawKeyboard();
const page = buildMockPage();
const cdpCalls: Array<[string, any]> = [];
const mockCdp = {
send: vi.fn(async (method: string, params: any) => {
cdpCalls.push([method, params]);
return {};
}),
};
await humanType(page, raw, allSymbols, cfg, mockCdp as any);
for (const [method, params] of cdpCalls) {
if (method === "Input.dispatchKeyEvent") {
expect(params.modifiers).toBe(8);
}
}
}, 30000);
});
// =========================================================================
// Non-ASCII text does NOT go through CDP shift path
// =========================================================================
describe("Puppeteer: non-ASCII text avoids CDP shift path", () => {
it("Cyrillic text uses insertText, not CDP", async () => {
const cfg = resolveConfig("default", { mistype_chance: 0 });
const { raw, insertedChars } = buildRawKeyboard();
const page = buildMockPage();
const mockCdp = { send: vi.fn(async () => ({})) };
await humanType(page, raw, "Привет", cfg, mockCdp as any);
expect(insertedChars.join("")).toBe("Привет");
expect(mockCdp.send).not.toHaveBeenCalled();
expect(page.evaluate).not.toHaveBeenCalled();
});
it("mixed text: ASCII + Cyrillic + shift symbol", async () => {
const cfg = resolveConfig("default", { mistype_chance: 0 });
const { raw, downKeys, insertedChars } = buildRawKeyboard();
const page = buildMockPage();
const cdpCalls: Array<[string, any]> = [];
const mockCdp = {
send: vi.fn(async (method: string, params: any) => {
cdpCalls.push([method, params]);
return {};
}),
};
await humanType(page, raw, "Hi! Мир", cfg, mockCdp as any);
expect(downKeys).toContain("Shift");
expect(downKeys).toContain("H");
expect(downKeys).toContain("i");
const keyEvents = cdpCalls.filter(([m]) => m === "Input.dispatchKeyEvent");
expect(keyEvents.length).toBe(2);
expect(downKeys).toContain(" ");
expect(insertedChars).toContain("М");
expect(insertedChars).toContain("и");
expect(insertedChars).toContain("р");
expect(page.evaluate).not.toHaveBeenCalled();
});
});
// =========================================================================
// Per-call human config override (Puppeteer page-level)
// =========================================================================
describe("Puppeteer: page.type accepts per-call human config override", () => {
it("page.type forwards merged config to humanType", async () => {
const keyboardMod = await import("../src/human-puppeteer/keyboard.js");
const scrollMod = await import("../src/human-puppeteer/scroll.js");
const cfg = resolveConfig("default", {
idle_between_actions: false,
field_switch_delay: [0, 1],
});
expect(cfg.typing_delay).toBe(70);
let captured: any = null;
const typeSpy = vi.spyOn(keyboardMod, "humanType").mockImplementation(
async (_page, _raw, _text, callCfg) => { captured = callCfg; },
);
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,
}),
);
const { patchPage } = await import("../src/human-puppeteer/index.js");
const page = buildMockPage();
const cursor = { x: 100, y: 100, initialized: true };
patchPage(page as any, cfg, cursor as any);
await (page as any).type("#email", "hi", {
typing_delay: 30,
mistype_chance: 0,
});
expect(captured.typing_delay).toBe(30);
expect(captured.mistype_chance).toBe(0);
expect(cfg.typing_delay).toBe(70);
typeSpy.mockRestore();
scrollSpy.mockRestore();
});
});
// =========================================================================
// patchPage stealth infrastructure (Puppeteer)
// =========================================================================
describe("Puppeteer: patchPage stealth infrastructure", () => {
it("page._stealth is a StealthEval instance after patching", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const page = buildMockPage();
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
expect((page as any)._stealth).toBeDefined();
expect(typeof (page as any)._stealth.evaluate).toBe("function");
expect(typeof (page as any)._stealth.invalidate).toBe("function");
expect(typeof (page as any)._stealth.getCdpSession).toBe("function");
});
it("page._original and page._humanCfg are set", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const page = buildMockPage();
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
expect((page as any)._original).toBeDefined();
expect((page as any)._humanCfg).toBe(cfg);
});
it("goto invalidates stealth context", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const page = buildMockPage();
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
const stealth = (page as any)._stealth;
const invalidateSpy = vi.spyOn(stealth, "invalidate");
await page.goto("https://example.com");
expect(invalidateSpy).toHaveBeenCalled();
});
it("internal helpers are stored on page for element/frame patching", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const page = buildMockPage();
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
// Only the helpers actually used by ElementHandle/Frame patching
expect(typeof (page as any)._ensureCursorInit).toBe("function");
expect((page as any)._humanCursor).toBeDefined();
expect((page as any)._humanRaw).toBeDefined();
expect((page as any)._humanRawKb).toBeDefined();
});
it("page.createCDPSession is used (not context.newCDPSession)", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const page = buildMockPage();
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
const stealth = (page as any)._stealth;
await stealth.getCdpSession();
expect(page.createCDPSession).toHaveBeenCalled();
});
});
// =========================================================================
// StealthEval lifecycle (Puppeteer — via page.createCDPSession)
// =========================================================================
describe("Puppeteer: StealthEval lifecycle", () => {
it("stealth.invalidate() is callable without error", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const page = buildMockPage();
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
const stealth = (page as any)._stealth;
expect(() => stealth.invalidate()).not.toThrow();
});
it("stealth.getCdpSession() returns a CDP session", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const page = buildMockPage();
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
const stealth = (page as any)._stealth;
const session = await stealth.getCdpSession();
expect(session).toBeDefined();
expect(typeof session.send).toBe("function");
});
it("stealth.evaluate() creates world and returns value", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const mockCdp = buildMockCDP({
send: vi.fn(async (method: string, params?: any) => {
if (method === "Page.getFrameTree") {
return { frameTree: { frame: { id: "F1" } } };
}
if (method === "Page.createIsolatedWorld") {
return { executionContextId: 42 };
}
if (method === "Runtime.evaluate") {
return { result: { value: true } };
}
return {};
}),
});
const page = buildMockPage();
page.createCDPSession = vi.fn(async () => mockCdp);
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
const stealth = (page as any)._stealth;
const result = await stealth.evaluate("1 + 1");
expect(result).toBe(true);
});
it("stealth.evaluate() retries on exceptionDetails", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
let attempt = 0;
const mockCdp = buildMockCDP({
send: vi.fn(async (method: string, params?: any) => {
if (method === "Page.getFrameTree") {
return { frameTree: { frame: { id: "F1" } } };
}
if (method === "Page.createIsolatedWorld") {
return { executionContextId: 50 + attempt };
}
if (method === "Runtime.evaluate") {
attempt++;
if (attempt === 1) {
return { exceptionDetails: { text: "stale" } };
}
return { result: { value: "recovered" } };
}
return {};
}),
});
const page = buildMockPage();
page.createCDPSession = vi.fn(async () => mockCdp);
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
const stealth = (page as any)._stealth;
const result = await stealth.evaluate("test");
expect(result).toBe("recovered");
});
it("stealth.evaluate() returns undefined after double failure", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const mockCdp = buildMockCDP({
send: vi.fn(async (method: string) => {
if (method === "Page.getFrameTree") {
return { frameTree: { frame: { id: "F1" } } };
}
if (method === "Page.createIsolatedWorld") {
return { executionContextId: 70 };
}
if (method === "Runtime.evaluate") {
return { exceptionDetails: { text: "always broken" } };
}
return {};
}),
});
const page = buildMockPage();
page.createCDPSession = vi.fn(async () => mockCdp);
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
const stealth = (page as any)._stealth;
const result = await stealth.evaluate("broken");
expect(result).toBeUndefined();
});
});
// =========================================================================
// focus() — human-like click instead of programmatic focus
// =========================================================================
describe("Puppeteer: focus() humanization", () => {
it("page.focus is replaced with humanized version", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const page = buildMockPage();
const originalFocus = page.focus;
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
expect(page.focus).not.toBe(originalFocus);
expect(typeof page.focus).toBe("function");
});
it("focus() calls click when element is not focused", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const mockCdp = buildMockCDP({
send: vi.fn(async (method: string, params?: any) => {
if (method === "Page.getFrameTree") {
return { frameTree: { frame: { id: "F1" } } };
}
if (method === "Page.createIsolatedWorld") {
return { executionContextId: 42 };
}
if (method === "Runtime.evaluate") {
// Return false = element is NOT focused → should click
return { result: { value: false } };
}
return {};
}),
});
const page = buildMockPage();
page.createCDPSession = vi.fn(async () => mockCdp);
const cfg = resolveConfig("default");
const cursor = { x: 100, y: 100, initialized: true };
patchPage(page as any, cfg, cursor as any);
// page.focus is patched — it delegates to click internally
expect(typeof page.focus).toBe("function");
// Verify it's not the original
expect(page.focus).not.toBe((page as any)._original.focus);
});
it("focus is patched on page for frame delegation", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const page = buildMockPage();
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
const originalFocus = page.focus;
patchPage(page as any, cfg, cursor as any);
// focus is patched directly on page — frames delegate to page.focus
expect(page.focus).not.toBe(originalFocus);
expect(typeof page.focus).toBe("function");
});
});
// =========================================================================
// uncheck() — fallback behavior (assume checked on error)
// =========================================================================
describe("Puppeteer: select() humanization", () => {
it("page.select is replaced with humanized version", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const page = buildMockPage();
const originalSelect = page.select;
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
expect(page.select).not.toBe(originalSelect);
expect(typeof page.select).toBe("function");
});
it("select() hovers before delegating to original", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const page = buildMockPage();
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
// Verify original select is stored
const originals = (page as any)._original;
expect(typeof originals.select).toBe("function");
});
});
// =========================================================================
// mouse.wheel() — smooth scroll
// =========================================================================
describe("Puppeteer: mouse.wheel() smooth scroll", () => {
it("mouse.wheel is patched after patchPage", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const page = buildMockPage();
const originalWheel = page.mouse.wheel;
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
expect(page.mouse.wheel).not.toBe(originalWheel);
});
it("mouse.wheel({deltaY: 300}) calls original wheel multiple times (smooth)", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const wheelCalls: any[] = [];
const page = buildMockPage();
const origWheel = vi.fn(async (opts: any) => { wheelCalls.push(opts); });
page.mouse.wheel = origWheel;
const cfg = resolveConfig("default");
const cursor = { x: 100, y: 100, initialized: true };
patchPage(page as any, cfg, cursor as any);
await page.mouse.wheel({ deltaY: 300 });
// smoothWheel breaks 300px into multiple small chunks (20-40px each)
// So original wheel should be called many times, not just once
expect(wheelCalls.length).toBeGreaterThan(1);
});
it("mouse.wheel({deltaX: 200}) smooths horizontal scroll too", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const wheelCalls: any[] = [];
const page = buildMockPage();
const origWheel = vi.fn(async (opts: any) => { wheelCalls.push(opts); });
page.mouse.wheel = origWheel;
const cfg = resolveConfig("default");
const cursor = { x: 100, y: 100, initialized: true };
patchPage(page as any, cfg, cursor as any);
await page.mouse.wheel({ deltaX: 200 });
expect(wheelCalls.length).toBeGreaterThan(1);
});
it("mouse.wheel with no args does nothing", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const wheelCalls: any[] = [];
const page = buildMockPage();
const origWheel = vi.fn(async (opts: any) => { wheelCalls.push(opts); });
page.mouse.wheel = origWheel;
const cfg = resolveConfig("default");
const cursor = { x: 100, y: 100, initialized: true };
patchPage(page as any, cfg, cursor as any);
await page.mouse.wheel({});
expect(wheelCalls.length).toBe(0);
});
});
// =========================================================================
// mouse.dragAndDrop() — Bézier drag between coordinates
// =========================================================================
describe("Puppeteer: mouse.dragAndDrop() humanization", () => {
it("mouse.dragAndDrop is patched after patchPage", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const page = buildMockPage();
const originalDnD = page.mouse.dragAndDrop;
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
expect(page.mouse.dragAndDrop).not.toBe(originalDnD);
});
it("mouse.dragAndDrop calls mouseMove multiple times (Bézier), then mouseDown/mouseUp", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const moveCalls: Array<[number, number]> = [];
const downCalls: any[] = [];
const upCalls: any[] = [];
const page = buildMockPage();
const origMove = vi.fn(async (x: number, y: number) => { moveCalls.push([x, y]); });
const origDown = vi.fn(async () => { downCalls.push(true); });
const origUp = vi.fn(async () => { upCalls.push(true); });
page.mouse.move = origMove;
page.mouse.down = origDown;
page.mouse.up = origUp;
const cfg = resolveConfig("default");
const cursor = { x: 50, y: 50, initialized: true };
patchPage(page as any, cfg, cursor as any);
await page.mouse.dragAndDrop(
{ x: 100, y: 100 },
{ x: 400, y: 400 },
);
// Bézier movement generates many intermediate points
expect(moveCalls.length).toBeGreaterThan(10);
// mouseDown and mouseUp should each be called once
expect(downCalls.length).toBe(1);
expect(upCalls.length).toBe(1);
});
});
// =========================================================================
// Keyboard patches — type, press, down, up
// =========================================================================
describe("Puppeteer: keyboard patches", () => {
it("keyboard.type is patched to use humanType", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const page = buildMockPage();
const originalType = page.keyboard.type;
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
expect(page.keyboard.type).not.toBe(originalType);
});
it("keyboard.press is patched with delay", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const page = buildMockPage();
const originalPress = page.keyboard.press;
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
expect(page.keyboard.press).not.toBe(originalPress);
});
it("keyboard.down is patched with small delay", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const downCalls: string[] = [];
const page = buildMockPage();
const origDown = page.keyboard.down;
page.keyboard.down = vi.fn(async (k: string) => { downCalls.push(k); });
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
expect(page.keyboard.down).not.toBe(origDown);
await page.keyboard.down("a");
// Original should have been called via the patch
// (the patched version calls originals.keyboardDown which is the stored original)
});
it("keyboard.up is patched with small delay", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const page = buildMockPage();
const origUp = page.keyboard.up;
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
expect(page.keyboard.up).not.toBe(origUp);
});
});
// =========================================================================
// Mouse patches — move, click with clickCount
// =========================================================================
describe("Puppeteer: mouse patches", () => {
it("mouse.move is patched with Bézier movement", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const moveCalls: Array<[number, number]> = [];
const page = buildMockPage();
page.mouse.move = vi.fn(async (x: number, y: number) => { moveCalls.push([x, y]); });
const cfg = resolveConfig("default");
const cursor = { x: 50, y: 50, initialized: true };
patchPage(page as any, cfg, cursor as any);
await page.mouse.move(500, 500);
// Bézier generates many intermediate points
expect(moveCalls.length).toBeGreaterThan(10);
});
it("mouse.click is patched with Bézier + humanClick", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const moveCalls: Array<[number, number]> = [];
const downCalls: any[] = [];
const upCalls: any[] = [];
const page = buildMockPage();
page.mouse.move = vi.fn(async (x: number, y: number) => { moveCalls.push([x, y]); });
page.mouse.down = vi.fn(async (opts?: any) => { downCalls.push(opts); });
page.mouse.up = vi.fn(async (opts?: any) => { upCalls.push(opts); });
const cfg = resolveConfig("default");
const cursor = { x: 50, y: 50, initialized: true };
patchPage(page as any, cfg, cursor as any);
await page.mouse.click(300, 300);
expect(moveCalls.length).toBeGreaterThan(5);
expect(downCalls.length).toBe(1);
expect(upCalls.length).toBe(1);
});
it("mouse.click with clickCount:2 triggers double-click sequence", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const downCalls: any[] = [];
const upCalls: any[] = [];
const page = buildMockPage();
page.mouse.move = vi.fn(async () => {});
page.mouse.down = vi.fn(async (opts?: any) => { downCalls.push(opts ?? {}); });
page.mouse.up = vi.fn(async (opts?: any) => { upCalls.push(opts ?? {}); });
const cfg = resolveConfig("default");
const cursor = { x: 50, y: 50, initialized: true };
patchPage(page as any, cfg, cursor as any);
await page.mouse.click(300, 300, { clickCount: 2 });
// First click: down() + up() (no clickCount), Second click: down({clickCount:2}) + up({clickCount:2})
expect(downCalls.length).toBe(2);
expect(upCalls.length).toBe(2);
// Second down/up should have clickCount:2
const secondDown = downCalls[1];
const secondUp = upCalls[1];
expect(secondDown.clickCount).toBe(2);
expect(secondUp.clickCount).toBe(2);
});
});
// =========================================================================
// select-all: Puppeteer uses down(modifier) → press('a') → up(modifier)
// =========================================================================
describe("Puppeteer: select-all via modifier keys (not combo string)", () => {
it("fill() calls keyboardDown(Control/Meta) → keyboardPress('a') → keyboardUp(Control/Meta)", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const keyDowns: string[] = [];
const keyUps: string[] = [];
const keyPresses: string[] = [];
const page = buildMockPage();
page.keyboard.down = vi.fn(async (k: string) => { keyDowns.push(k); });
page.keyboard.up = vi.fn(async (k: string) => { keyUps.push(k); });
page.keyboard.press = vi.fn(async (k: string) => { keyPresses.push(k); });
const cfg = resolveConfig("default", {
field_switch_delay: [0, 0],
idle_between_actions: false,
});
const cursor = { x: 100, y: 100, initialized: true };
patchPage(page as any, cfg, cursor as any);
// fill → click → selectAll → Backspace → type
// We can't easily call fill without scrollToElement working,
// but we can test pressSelectAll indirectly by checking originals are stored
const originals = (page as any)._original;
expect(typeof originals.keyboardDown).toBe("function");
expect(typeof originals.keyboardPress).toBe("function");
expect(typeof originals.keyboardUp).toBe("function");
// Verify the modifier is correct for the platform
const expectedModifier = process.platform === 'darwin' ? 'Meta' : 'Control';
// Test by importing and calling pressSelectAll-equivalent logic
await originals.keyboardDown(expectedModifier);
await originals.keyboardPress('a');
await originals.keyboardUp(expectedModifier);
expect(keyDowns).toContain(expectedModifier);
expect(keyPresses).toContain('a');
expect(keyUps).toContain(expectedModifier);
});
});
// =========================================================================
// ElementHandle patching (Puppeteer-specific)
// =========================================================================
describe("Puppeteer: ElementHandle patching", () => {
it("page.$() returns patched ElementHandle", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const mockEl = buildMockElementHandle();
const page = buildMockPage({
$: vi.fn(async () => mockEl),
});
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
const el = await page.$('#test');
expect(el).toBeDefined();
expect((el as any)._humanPatched).toBe(true);
});
it("page.$$() returns patched ElementHandles", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const mockEl1 = buildMockElementHandle();
const mockEl2 = buildMockElementHandle();
const page = buildMockPage({
$$: vi.fn(async () => [mockEl1, mockEl2]),
});
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
const els = await page.$$('.items');
expect(els.length).toBe(2);
expect((els[0] as any)._humanPatched).toBe(true);
expect((els[1] as any)._humanPatched).toBe(true);
});
it("page.waitForSelector() returns patched ElementHandle", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const mockEl = buildMockElementHandle();
const page = buildMockPage({
waitForSelector: vi.fn(async () => mockEl),
});
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
const el = await page.waitForSelector('#loading');
expect(el).toBeDefined();
expect((el as any)._humanPatched).toBe(true);
});
it("patched el.click() uses humanMove + humanClick", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const moveCalls: Array<[number, number]> = [];
const downCalls: any[] = [];
const upCalls: any[] = [];
const mockEl = buildMockElementHandle({
boundingBox: vi.fn(async () => ({ x: 200, y: 300, width: 100, height: 30 })),
evaluate: vi.fn(async () => false), // not an input
});
const page = buildMockPage({
$: vi.fn(async () => mockEl),
});
page.mouse.move = vi.fn(async (x: number, y: number) => { moveCalls.push([x, y]); });
page.mouse.down = vi.fn(async (opts?: any) => { downCalls.push(opts); });
page.mouse.up = vi.fn(async (opts?: any) => { upCalls.push(opts); });
const cfg = resolveConfig("default", { idle_between_actions: false });
const cursor = { x: 50, y: 50, initialized: true };
patchPage(page as any, cfg, cursor as any);
const el = await page.$('#btn');
await el.click();
// Bézier movement → multiple move calls
expect(moveCalls.length).toBeGreaterThan(5);
// Click → down + up
expect(downCalls.length).toBe(1);
expect(upCalls.length).toBe(1);
});
it("patched el.click({clickCount:2}) triggers double-click", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const downCalls: any[] = [];
const upCalls: any[] = [];
const mockEl = buildMockElementHandle({
boundingBox: vi.fn(async () => ({ x: 200, y: 300, width: 100, height: 30 })),
evaluate: vi.fn(async () => false),
});
const page = buildMockPage({
$: vi.fn(async () => mockEl),
});
page.mouse.move = vi.fn(async () => {});
page.mouse.down = vi.fn(async (opts?: any) => { downCalls.push(opts ?? {}); });
page.mouse.up = vi.fn(async (opts?: any) => { upCalls.push(opts ?? {}); });
const cfg = resolveConfig("default", { idle_between_actions: false });
const cursor = { x: 50, y: 50, initialized: true };
patchPage(page as any, cfg, cursor as any);
const el = await page.$('#text');
await el.click({ clickCount: 2 });
// First click + second click with clickCount:2
expect(downCalls.length).toBe(2);
expect(upCalls.length).toBe(2);
expect(downCalls[1].clickCount).toBe(2);
expect(upCalls[1].clickCount).toBe(2);
});
it("patched el.hover() uses humanMove without clicking", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const moveCalls: Array<[number, number]> = [];
const downCalls: any[] = [];
const mockEl = buildMockElementHandle({
boundingBox: vi.fn(async () => ({ x: 150, y: 250, width: 80, height: 25 })),
evaluate: vi.fn(async () => false),
});
const page = buildMockPage({
$: vi.fn(async () => mockEl),
});
page.mouse.move = vi.fn(async (x: number, y: number) => { moveCalls.push([x, y]); });
page.mouse.down = vi.fn(async (opts?: any) => { downCalls.push(opts); });
const cfg = resolveConfig("default", { idle_between_actions: false });
const cursor = { x: 50, y: 50, initialized: true };
patchPage(page as any, cfg, cursor as any);
const el = await page.$('#link');
await el.hover();
expect(moveCalls.length).toBeGreaterThan(5);
// Hover should NOT click
expect(downCalls.length).toBe(0);
});
it("patched el.type() moves, clicks, then types with humanType", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const downCalls: any[] = [];
const charCalls: string[] = [];
const mockEl = buildMockElementHandle({
boundingBox: vi.fn(async () => ({ x: 100, y: 200, width: 200, height: 30 })),
evaluate: vi.fn(async () => true), // is an input
});
const page = buildMockPage({
$: vi.fn(async () => mockEl),
});
page.mouse.move = vi.fn(async () => {});
page.mouse.down = vi.fn(async (opts?: any) => { downCalls.push(opts); });
page.mouse.up = vi.fn(async () => {});
page.keyboard.down = vi.fn(async (k: string) => { charCalls.push(`down:${k}`); });
page.keyboard.up = vi.fn(async (k: string) => { charCalls.push(`up:${k}`); });
page.keyboard.sendCharacter = vi.fn(async () => {});
const cfg = resolveConfig("default", {
mistype_chance: 0,
idle_between_actions: false,
typing_delay: 0,
typing_delay_spread: 0,
key_hold: [0, 0],
});
const cursor = { x: 50, y: 50, initialized: true };
patchPage(page as any, cfg, cursor as any);
const el = await page.$('#email');
await el.type('ab');
// Should have clicked first (mouseDown)
expect(downCalls.length).toBe(1);
// Should have typed 'a' and 'b' via keyboard.down/up
expect(charCalls).toContain('down:a');
expect(charCalls).toContain('up:a');
expect(charCalls).toContain('down:b');
expect(charCalls).toContain('up:b');
});
it("patched el.focus() clicks to focus instead of programmatic focus", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const downCalls: any[] = [];
const mockEl = buildMockElementHandle({
boundingBox: vi.fn(async () => ({ x: 100, y: 200, width: 200, height: 30 })),
evaluate: vi.fn(async () => true),
});
const page = buildMockPage({
$: vi.fn(async () => mockEl),
});
page.mouse.move = vi.fn(async () => {});
page.mouse.down = vi.fn(async (opts?: any) => { downCalls.push(opts); });
page.mouse.up = vi.fn(async () => {});
const cfg = resolveConfig("default", { idle_between_actions: false });
const cursor = { x: 50, y: 50, initialized: true };
patchPage(page as any, cfg, cursor as any);
const el = await page.$('#input');
await el.focus();
// focus should trigger a click (mouseDown + mouseUp)
expect(downCalls.length).toBe(1);
});
it("el with null boundingBox falls back to original", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const originalClickCalled = { value: false };
const mockEl = buildMockElementHandle({
boundingBox: vi.fn(async () => null), // not visible
});
// Override the original click to track it
mockEl.click = vi.fn(async () => { originalClickCalled.value = true; });
const page = buildMockPage({
$: vi.fn(async () => mockEl),
});
const cfg = resolveConfig("default");
const cursor = { x: 50, y: 50, initialized: true };
patchPage(page as any, cfg, cursor as any);
const el = await page.$('#hidden');
await el.click();
// Should have fallen back to original click
expect(originalClickCalled.value).toBe(true);
});
it("nested el.$() returns patched child", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const childEl = buildMockElementHandle();
const parentEl = buildMockElementHandle();
parentEl.$ = vi.fn(async () => childEl);
const page = buildMockPage({
$: vi.fn(async () => parentEl),
});
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
const parent = await page.$('#parent');
const child = await parent.$('.child');
expect(child).toBeDefined();
expect((child as any)._humanPatched).toBe(true);
});
it("double-patching is prevented (_humanPatched guard)", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const mockEl = buildMockElementHandle();
const page = buildMockPage({
$: vi.fn(async () => mockEl),
});
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
// First call
const el1 = await page.$('#btn');
const clickFn1 = el1.click;
// Second call — same element, should not re-patch
const el2 = await page.$('#btn');
const clickFn2 = el2.click;
// Functions should be identical (not re-wrapped)
expect(clickFn1).toBe(clickFn2);
});
});
// =========================================================================
// Frame-level patching
// =========================================================================
describe("Puppeteer: frame-level patching", () => {
it("child frames are marked _humanPatched", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const childFrame: any = {
click: vi.fn(async () => {}),
hover: vi.fn(async () => {}),
type: vi.fn(async () => {}),
fill: vi.fn(async () => {}),
check: vi.fn(async () => {}),
uncheck: vi.fn(async () => {}),
select: vi.fn(async () => []),
press: vi.fn(async () => {}),
clear: vi.fn(async () => {}),
focus: vi.fn(async () => {}),
tap: vi.fn(async () => {}),
pressSequentially: vi.fn(async () => {}),
dragAndDrop: vi.fn(async () => {}),
$: vi.fn(async () => null),
$$: vi.fn(async () => []),
waitForSelector: vi.fn(async () => null),
childFrames: vi.fn(() => []),
};
const mainFrame = {
...childFrame,
childFrames: vi.fn(() => [childFrame]),
};
const page = buildMockPage({ mainFrameReturn: mainFrame });
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
expect((childFrame as any)._humanPatched).toBe(true);
});
it("frame.focus is patched to delegate to page.focus", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const childFrame: any = {
click: vi.fn(async () => {}),
hover: vi.fn(async () => {}),
type: vi.fn(async () => {}),
fill: vi.fn(async () => {}),
check: vi.fn(async () => {}),
uncheck: vi.fn(async () => {}),
select: vi.fn(async () => []),
press: vi.fn(async () => {}),
clear: vi.fn(async () => {}),
focus: vi.fn(async () => {}),
tap: vi.fn(async () => {}),
pressSequentially: vi.fn(async () => {}),
dragAndDrop: vi.fn(async () => {}),
$: vi.fn(async () => null),
$$: vi.fn(async () => []),
waitForSelector: vi.fn(async () => null),
childFrames: vi.fn(() => []),
};
const mainFrame = {
...childFrame,
childFrames: vi.fn(() => [childFrame]),
};
const page = buildMockPage({ mainFrameReturn: mainFrame });
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
// frame.focus should now be patched (not the original)
const originalFrameFocus = vi.fn(async () => {});
expect(childFrame.focus).not.toBe(originalFrameFocus);
expect(typeof childFrame.focus).toBe("function");
});
it("frame.$() returns patched ElementHandles", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const mockEl = buildMockElementHandle();
const childFrame: any = {
click: vi.fn(async () => {}),
hover: vi.fn(async () => {}),
type: vi.fn(async () => {}),
fill: vi.fn(async () => {}),
check: vi.fn(async () => {}),
uncheck: vi.fn(async () => {}),
select: vi.fn(async () => []),
press: vi.fn(async () => {}),
clear: vi.fn(async () => {}),
focus: vi.fn(async () => {}),
tap: vi.fn(async () => {}),
pressSequentially: vi.fn(async () => {}),
dragAndDrop: vi.fn(async () => {}),
$: vi.fn(async () => mockEl),
$$: vi.fn(async () => []),
waitForSelector: vi.fn(async () => null),
childFrames: vi.fn(() => []),
};
const mainFrame = {
...childFrame,
childFrames: vi.fn(() => [childFrame]),
};
const page = buildMockPage({ mainFrameReturn: mainFrame });
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
const el = await childFrame.$('#in-frame');
expect(el).toBeDefined();
expect((el as any)._humanPatched).toBe(true);
});
});
// =========================================================================
// Browser-level patching
// =========================================================================
describe("Puppeteer: browser-level patching", () => {
it("patchBrowser patches newPage()", async () => {
const { patchBrowser } = await import("../src/human-puppeteer/index.js");
const browser = buildMockBrowser();
const origNewPage = browser.newPage;
const cfg = resolveConfig("default");
patchBrowser(browser as any, cfg);
expect(browser.newPage).not.toBe(origNewPage);
expect(typeof browser.newPage).toBe("function");
});
it("patchBrowser patches createIncognitoBrowserContext()", async () => {
const { patchBrowser } = await import("../src/human-puppeteer/index.js");
const browser: any = {
pages: vi.fn(async () => []),
newPage: vi.fn(async () => buildMockPage()),
createIncognitoBrowserContext: vi.fn(async () => ({
newPage: vi.fn(async () => buildMockPage()),
})),
on: vi.fn(),
close: vi.fn(async () => {}),
};
const origCreateCtx = browser.createIncognitoBrowserContext;
const cfg = resolveConfig("default");
patchBrowser(browser as any, cfg);
expect(browser.createIncognitoBrowserContext).not.toBe(origCreateCtx);
});
it("patchBrowser listens for targetcreated event", async () => {
const { patchBrowser } = await import("../src/human-puppeteer/index.js");
const browser = buildMockBrowser();
const cfg = resolveConfig("default");
patchBrowser(browser as any, cfg);
expect(browser.on).toHaveBeenCalledWith("targetcreated", expect.any(Function));
});
it("newPage from patched browser returns page with _original", async () => {
const { patchBrowser } = await import("../src/human-puppeteer/index.js");
const mockPage = buildMockPage();
const browser = buildMockBrowser();
const origNewPage = vi.fn(async () => mockPage);
browser.newPage = origNewPage;
const cfg = resolveConfig("default");
patchBrowser(browser as any, cfg);
const page = await browser.newPage();
expect((page as any)._original).toBeDefined();
expect((page as any)._humanCfg).toBe(cfg);
expect((page as any)._stealth).toBeDefined();
});
it("pages from patched createIncognitoBrowserContext also get humanized", async () => {
const { patchBrowser } = await import("../src/human-puppeteer/index.js");
const mockPage = buildMockPage();
const mockCtx = {
newPage: vi.fn(async () => mockPage),
};
const browser: any = {
pages: vi.fn(async () => []),
newPage: vi.fn(async () => buildMockPage()),
createIncognitoBrowserContext: vi.fn(async () => mockCtx),
on: vi.fn(),
close: vi.fn(async () => {}),
};
const cfg = resolveConfig("default");
patchBrowser(browser as any, cfg);
const ctx = await browser.createIncognitoBrowserContext();
const page = await ctx.newPage();
expect((page as any)._original).toBeDefined();
expect((page as any)._humanCfg).toBe(cfg);
});
});
// =========================================================================
// isInputElement / isSelectorFocused — through patchPage click flow
// =========================================================================
describe("Puppeteer: isInputElement stealth integration via patchPage", () => {
it("click() uses stealth.evaluate for isInputElement (no page.evaluate)", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const evaluateCalls: any[] = [];
const stealthEvaluateCalls: string[] = [];
const mockCdp = buildMockCDP({
send: vi.fn(async (method: string, params?: any) => {
if (method === "Page.getFrameTree") {
return { frameTree: { frame: { id: "F1" } } };
}
if (method === "Page.createIsolatedWorld") {
return { executionContextId: 100 };
}
if (method === "Runtime.evaluate") {
stealthEvaluateCalls.push(params.expression);
return { result: { value: false } };
}
return {};
}),
});
const mockEl = buildMockElementHandle();
const page = buildMockPage({
evaluate: vi.fn(async (...args: any[]) => {
evaluateCalls.push(args);
return false;
}),
$: vi.fn(async () => mockEl),
});
page.createCDPSession = vi.fn(async () => mockCdp);
const cfg = resolveConfig("default", { idle_between_actions: false });
const cursor = { x: 100, y: 100, initialized: true };
patchPage(page as any, cfg, cursor as any);
try {
await page.click("#btn");
} catch (e) {
// scrollToElement might throw with mocks
}
const isInputCalls = stealthEvaluateCalls.filter(
expr => expr.includes("tagName") || expr.includes("querySelector")
);
const qsCalls = evaluateCalls.filter(
args => typeof args[0] === "string" && args[0].includes("querySelector")
);
if (isInputCalls.length > 0) {
expect(qsCalls.length).toBe(0);
}
});
});
// =========================================================================
// isSelectorFocused stealth integration via patchPage press flow
// =========================================================================
describe("Puppeteer: isSelectorFocused stealth integration via patchPage", () => {
it("focus() uses stealth.evaluate for focus check", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const stealthEvaluateCalls: string[] = [];
const mockCdp = buildMockCDP({
send: vi.fn(async (method: string, params?: any) => {
if (method === "Page.getFrameTree") {
return { frameTree: { frame: { id: "F1" } } };
}
if (method === "Page.createIsolatedWorld") {
return { executionContextId: 200 };
}
if (method === "Runtime.evaluate") {
stealthEvaluateCalls.push(params.expression);
// Return true = element IS focused → focus() should skip clicking
return { result: { value: true } };
}
return {};
}),
});
const page = buildMockPage({
evaluate: vi.fn(async () => true),
});
page.createCDPSession = vi.fn(async () => mockCdp);
const cfg = resolveConfig("default");
const cursor = { x: 50, y: 50, initialized: true };
patchPage(page as any, cfg, cursor as any);
try {
await page.focus("input#field");
} catch (e) {
// May throw with mocks — that's fine, we just need the stealth call
}
const focusCalls = stealthEvaluateCalls.filter(
expr => expr.includes("activeElement")
);
expect(focusCalls.length).toBeGreaterThan(0);
});
});
// =========================================================================
// Puppeteer-specific: sendCharacter mapped to insertText
// =========================================================================
describe("Puppeteer: sendCharacter → insertText mapping", () => {
it("rawKb.insertText is mapped from keyboard.sendCharacter", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const sentChars: string[] = [];
const page = buildMockPage();
page.keyboard.sendCharacter = vi.fn(async (ch: string) => { sentChars.push(ch); });
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
const originals = (page as any)._original;
expect(typeof originals.keyboardSendCharacter).toBe("function");
// rawKb.insertText should use the original sendCharacter
await originals.keyboardSendCharacter("€");
expect(sentChars).toContain("€");
});
});
// =========================================================================
// Puppeteer-specific: viewport() vs viewportSize()
// =========================================================================
describe("Puppeteer: viewport() API (not viewportSize)", () => {
it("patchPage works with page.viewport() (Puppeteer API)", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const page = buildMockPage();
// Puppeteer uses viewport(), not viewportSize()
page.viewport = vi.fn(() => ({ width: 1920, height: 1080 }));
expect(page.viewportSize).toBeUndefined; // should NOT exist
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
// Should not throw
expect(() => patchPage(page as any, cfg, cursor as any)).not.toThrow();
});
});
// =========================================================================
// Cursor initialization
// =========================================================================
describe("Puppeteer: cursor initialization", () => {
it("cursor is initialized on patchPage with random position", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const moveCalls: Array<[number, number]> = [];
const page = buildMockPage();
page.mouse.move = vi.fn(async (x: number, y: number) => { moveCalls.push([x, y]); });
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
// Wait for the async init
await sleep(50);
// Cursor should be initialized within the config range
expect(cursor.x).toBeGreaterThanOrEqual(cfg.initial_cursor_x[0]);
expect(cursor.x).toBeLessThanOrEqual(cfg.initial_cursor_x[1]);
expect(cursor.y).toBeGreaterThanOrEqual(cfg.initial_cursor_y[0]);
expect(cursor.y).toBeLessThanOrEqual(cfg.initial_cursor_y[1]);
expect(cursor.initialized).toBe(true);
});
});
// =========================================================================
// Page-level method replacement verification
// =========================================================================
describe("Puppeteer: all page methods are replaced", () => {
it("all interaction methods are replaced after patchPage", async () => {
const { patchPage } = await import("../src/human-puppeteer/index.js");
const page = buildMockPage();
const originals = {
goto: page.goto,
click: page.click,
hover: page.hover,
type: page.type,
select: page.select,
focus: page.focus,
tap: page.tap,
mouseMove: page.mouse.move,
mouseClick: page.mouse.click,
mouseWheel: page.mouse.wheel,
keyboardType: page.keyboard.type,
keyboardPress: page.keyboard.press,
keyboardDown: page.keyboard.down,
keyboardUp: page.keyboard.up,
};
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
expect(page.goto).not.toBe(originals.goto);
expect(page.click).not.toBe(originals.click);
expect(page.hover).not.toBe(originals.hover);
expect(page.type).not.toBe(originals.type);
expect(page.select).not.toBe(originals.select);
expect(page.focus).not.toBe(originals.focus);
expect(page.mouse.move).not.toBe(originals.mouseMove);
expect(page.mouse.click).not.toBe(originals.mouseClick);
expect(page.mouse.wheel).not.toBe(originals.mouseWheel);
expect(page.keyboard.type).not.toBe(originals.keyboardType);
expect(page.keyboard.press).not.toBe(originals.keyboardPress);
expect(page.keyboard.down).not.toBe(originals.keyboardDown);
expect(page.keyboard.up).not.toBe(originals.keyboardUp);
});
});
// =========================================================================
// SLOW TESTS — require real browser (run with: vitest run --testTimeout=60000)
// Only run when SLOW=1 env var is set
// =========================================================================
const SLOW = process.env.SLOW === '1';
const describeIfSlow = SLOW ? describe : describe.skip;
describeIfSlow("Puppeteer stealth browser: no evaluate leak on click", () => {
it("click() does not trigger querySelector from evaluate context", async () => {
const { launch } = await import("../src/puppeteer.js");
const browser = await launch({ humanize: true, headless: true });
const page = await browser.newPage();
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
await sleep(1000);
// Inject detection script
await page.evaluate(() => {
(window as any).__evalLeaks = [];
const origQS = document.querySelector.bind(document);
document.querySelector = function (sel: string) {
try { throw new Error(); } catch (e: any) {
if (e.stack && e.stack.includes(':302:')) {
(window as any).__evalLeaks.push(sel);
}
}
return origQS(sel);
} as any;
});
await page.click('#searchInput');
await sleep(500);
const leaks = await page.evaluate(() => (window as any).__evalLeaks || []);
expect(leaks.length).toBe(0);
await browser.close();
}, 30000);
});
describeIfSlow("Puppeteer stealth browser: shift symbols isTrusted=true", () => {
it("'!' produces isTrusted=true keydown, not isTrusted=false", async () => {
const { launch } = await import("../src/puppeteer.js");
const browser = await launch({ humanize: true, headless: true });
const page = await browser.newPage();
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
await sleep(1000);
await page.evaluate(() => {
(window as any).__untrustedKeys = [];
(window as any).__trustedKeys = [];
const input = document.querySelector('#searchInput');
if (input) {
input.addEventListener('keydown', (e) => {
if (!e.isTrusted) {
(window as any).__untrustedKeys.push((e as KeyboardEvent).key);
} else {
(window as any).__trustedKeys.push((e as KeyboardEvent).key);
}
}, true);
}
});
await page.click('#searchInput');
await sleep(300);
await page.keyboard.type('test!');
await sleep(500);
const untrusted = await page.evaluate(() => (window as any).__untrustedKeys || []);
const trusted = await page.evaluate(() => (window as any).__trustedKeys || []);
expect(untrusted).not.toContain('!');
expect(trusted).toContain('!');
await browser.close();
}, 30000);
});
describeIfSlow("Puppeteer stealth browser: navigation invalidation", () => {
it("click works after navigation (isolated world re-created)", async () => {
const { launch } = await import("../src/puppeteer.js");
const browser = await launch({ humanize: true, headless: true });
const page = await browser.newPage();
expect((page as any)._stealth).toBeDefined();
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
await sleep(1000);
await page.click('#searchInput');
await sleep(300);
// Second navigation
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
await sleep(1000);
// Should still work
await page.click('#searchInput');
await sleep(300);
await page.keyboard.type('after navigation');
await sleep(500);
// Puppeteer doesn't have locator().inputValue(), use evaluate instead
const val = await page.evaluate(() => {
const el = document.querySelector('#searchInput') as HTMLInputElement;
return el ? el.value : '';
});
expect(val).toContain('after navigation');
await browser.close();
}, 60000);
});
describeIfSlow("Puppeteer stealth browser: ElementHandle humanization", () => {
it("el.click() uses Bézier movement, not instant CDP click", async () => {
const { launch } = await import("../src/puppeteer.js");
const browser = await launch({ humanize: true, headless: true });
const page = await browser.newPage();
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
await sleep(1000);
// Track mousemove events at document level
await page.evaluate(() => {
(window as any).__moveCounts = 0;
document.addEventListener('mousemove', () => {
(window as any).__moveCounts++;
}, true);
});
// <h1> on example.com — always present, clickable, no navigation
const el = await page.$('h1');
expect(el).not.toBeNull();
expect((el as any)._humanPatched).toBe(true);
// Reset counter right before the click
await page.evaluate(() => { (window as any).__moveCounts = 0; });
// Click via ElementHandle — should use Bézier curve
await el!.click();
await sleep(500);
const moveCount = await page.evaluate(() => (window as any).__moveCounts || 0);
// Bézier movement generates many intermediate mousemove events (>10)
// An instant CDP dispatchMouseEvent would generate 0 or 1
expect(moveCount).toBeGreaterThan(5);
await browser.close();
}, 30000);
});
describeIfSlow("Puppeteer stealth browser: focus() uses click", () => {
it("page.focus() triggers mouse events (not programmatic focus)", async () => {
const { launch } = await import("../src/puppeteer.js");
const browser = await launch({ humanize: true, headless: true });
const page = await browser.newPage();
await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' });
await sleep(2000);
// Click somewhere else first to ensure #searchInput is NOT focused
await page.mouse.click(50, 50);
await sleep(500);
// Inject event tracking on #searchInput AFTER ensuring it's not focused
await page.evaluate(() => {
(window as any).__mouseEvents = [];
const input = document.querySelector('#searchInput');
if (input) {
for (const evt of ['mousedown', 'mouseup', 'click', 'mousemove']) {
input.addEventListener(evt, (e) => {
(window as any).__mouseEvents.push(evt);
}, true);
}
}
});
// Now focus — should trigger humanized click with mouse events
await page.focus('#searchInput');
await sleep(1000);
const events = await page.evaluate(() => (window as any).__mouseEvents || []);
expect(events.length).toBeGreaterThan(0);
expect(events).toContain('mousedown');
await browser.close();
}, 30000);
});
describeIfSlow("Puppeteer stealth browser: mouse.wheel smooth scroll", () => {
it("mouse.wheel generates multiple small scroll events", async () => {
const { launch } = await import("../src/puppeteer.js");
const browser = await launch({ humanize: true, headless: true });
const page = await browser.newPage();
await page.goto('https://en.wikipedia.org/wiki/Main_Page', { waitUntil: 'domcontentloaded' });
await sleep(1000);
// Track scroll events
await page.evaluate(() => {
(window as any).__wheelEvents = 0;
window.addEventListener('wheel', () => {
(window as any).__wheelEvents++;
}, { passive: true });
});
await page.mouse.wheel({ deltaY: 500 });
await sleep(1000);
const wheelCount = await page.evaluate(() => (window as any).__wheelEvents || 0);
// Smooth scroll should generate multiple wheel events, not just 1
expect(wheelCount).toBeGreaterThan(3);
await browser.close();
}, 30000);
});