/** * Unit tests for stealth / anti-detection fixes (issue #110). * * 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 * * 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/keyboard.js"; import { humanMove, humanClick, clickTarget, humanIdle } from "../src/human/mouse.js"; // ========================================================================= // Helper: build mock page / raw objects // ========================================================================= function buildMockPage(overrides: Record = {}): any { const mainFrameObj = overrides.mainFrameReturn ?? { childFrames: vi.fn(() => []), click: vi.fn(async () => {}), dblclick: vi.fn(async () => {}), hover: vi.fn(async () => {}), type: vi.fn(async () => {}), fill: vi.fn(async () => {}), check: vi.fn(async () => {}), uncheck: vi.fn(async () => {}), selectOption: vi.fn(async () => {}), 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; }), })), }; const makeLocator = () => { const loc: any = { 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), addInitScript: vi.fn(async () => {}), mouse: { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), click: vi.fn(async () => {}), dblclick: vi.fn(async () => {}), wheel: 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 () => {}), insertText: vi.fn(async () => {}), }, click: vi.fn(async () => {}), dblclick: vi.fn(async () => {}), hover: vi.fn(async () => {}), type: vi.fn(async () => {}), fill: vi.fn(async () => {}), check: vi.fn(async () => {}), uncheck: vi.fn(async () => {}), selectOption: vi.fn(async () => {}), press: vi.fn(async () => {}), goto: vi.fn(async () => ({})), isChecked: overrides.isChecked ?? vi.fn(async () => false), locator: vi.fn(() => makeLocator()), viewportSize: vi.fn(() => ({ width: 1280, height: 720 })), mainFrame: vi.fn(() => mainFrameObj), frames: vi.fn(() => []), context: vi.fn(() => ({ pages: vi.fn(() => []), addInitScript: vi.fn(async () => {}), newCDPSession: vi.fn(async () => buildMockCDP()), })), url: vi.fn(() => "about:blank"), waitForTimeout: vi.fn(async () => {}), }; return page; } function buildMockCDP(overrides: Record = {}): 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 }; } // ========================================================================= // SHIFT_SYMBOL_CODES / SHIFT_SYMBOL_KEYCODES completeness // ========================================================================= describe("SHIFT_SYMBOL maps completeness", () => { it("every shift symbol has a code and keycode entry", async () => { // We access these via dynamic import to get internal constants // Since they're not exported directly, we test via humanType behavior const cfg = resolveConfig("default", { mistype_chance: 0 }); const SHIFT_SYMBOLS = ['@', '#', '!', '$', '%', '^', '&', '*', '(', ')', '_', '+', '{', '}', '|', ':', '"', '<', '>', '?', '~']; // Each shift symbol should work via CDP path without error 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); // CDP path: should have called cdp.send for keyDown + keyUp const cdpCalls = mockCdp.send.mock.calls; const keyEvents = cdpCalls.filter( (c: any[]) => c[0] === "Input.dispatchKeyEvent" ); expect(keyEvents.length).toBe(2); // page.evaluate should NOT have been called (stealth path) 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); // Shift flag 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 // ========================================================================= describe("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); // insertText should NOT be called for shift symbols in CDP path 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); // Expected order: raw.down(Shift) → cdp.keyDown → cdp.keyUp → raw.up(Shift) 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 // ========================================================================= describe("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); // 'a' → raw.down('a') + raw.up('a') expect(downKeys).toContain("a"); // '!' → CDP keyDown + keyUp const keyEvents = cdpCalls.filter( ([m]) => m === "Input.dispatchKeyEvent" ); expect(keyEvents.length).toBe(2); // No page.evaluate 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(); // 3 symbols × 2 events = 6 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, typing_delay: 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" ); // Only '!' triggers CDP: 2 events (keyDown + keyUp) 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 () => { // Убираем задержки в 0, чтобы 21 символ не вызывал таймаут в 5 секунд 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); }); // ========================================================================= // patchPage stealth wiring // ========================================================================= describe("patchPage stealth infrastructure", () => { it("page._stealth is a StealthEval instance after patching", async () => { const { patchPage } = await import("../src/human/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/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/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("humanClickFn is stored for frame patching", async () => { const { patchPage } = await import("../src/human/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(typeof (page as any)._humanClickFn).toBe("function"); expect(typeof (page as any)._humanHoverFn).toBe("function"); expect(typeof (page as any)._humanClearFn).toBe("function"); expect(typeof (page as any)._humanPressFn).toBe("function"); }); }); // ========================================================================= // StealthEval lifecycle (via patchPage) // ========================================================================= describe("StealthEval lifecycle", () => { it("stealth.invalidate() is callable without error", async () => { const { patchPage } = await import("../src/human/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/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/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.context = vi.fn(() => ({ pages: vi.fn(() => []), addInitScript: vi.fn(async () => {}), newCDPSession: 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/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.context = vi.fn(() => ({ pages: vi.fn(() => []), addInitScript: vi.fn(async () => {}), newCDPSession: 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/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.context = vi.fn(() => ({ pages: vi.fn(() => []), addInitScript: vi.fn(async () => {}), newCDPSession: 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(); }); }); // ========================================================================= // isInputElement / isSelectorFocused — through patchPage click flow // ========================================================================= describe("isInputElement stealth integration via patchPage", () => { it("click() uses stealth.evaluate for isInputElement (no page.evaluate)", async () => { const { patchPage } = await import("../src/human/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); if (params.expression.includes("elementFromPoint")) { return { result: { value: { hit: true } } }; } return { result: { value: false } }; // not an input } return {}; }), }); const page = buildMockPage({ evaluate: vi.fn(async (...args: any[]) => { evaluateCalls.push(args); return { hit: true }; }), }); page.context = vi.fn(() => ({ pages: vi.fn(() => []), addInitScript: vi.fn(async () => {}), newCDPSession: 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; that's fine } // The stealth path should have been used for isInputElement // (Runtime.evaluate in isolated world, NOT page.evaluate) const isInputCalls = stealthEvaluateCalls.filter( expr => expr.includes("tagName") || expr.includes("querySelector") ); // We expect at least one stealth evaluate for the isInputElement check // OR page.evaluate was NOT called for this purpose // The key assertion: page.evaluate is NOT used for querySelector-based DOM checks const qsCalls = evaluateCalls.filter( args => typeof args[0] === "string" && args[0].includes("querySelector") ); // If stealth worked, no querySelector calls should go through page.evaluate if (isInputCalls.length > 0) { expect(qsCalls.length).toBe(0); } }); }); // ========================================================================= // isSelectorFocused stealth integration via patchPage press flow // ========================================================================= describe("isSelectorFocused stealth integration via patchPage", () => { it("press() uses stealth.evaluate for focus check", async () => { const { patchPage } = await import("../src/human/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 → skip click return { result: { value: true } }; } return {}; }), }); const pressedKeys: string[] = []; const page = buildMockPage({ evaluate: vi.fn(async () => true), keyboardPress: async (key: string) => { pressedKeys.push(key); }, }); page.context = vi.fn(() => ({ pages: vi.fn(() => []), addInitScript: vi.fn(async () => {}), newCDPSession: 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.press("input#field", "Enter"); } catch (e) { // May throw with mocks } // Focus check should use isolated world (Runtime.evaluate with activeElement) const focusCalls = stealthEvaluateCalls.filter( expr => expr.includes("activeElement") ); expect(focusCalls.length).toBeGreaterThan(0); }); }); // ========================================================================= // Frame patching with stealth // ========================================================================= describe("frame patching with stealth", () => { it("child frames use stealth for clear() focus check", async () => { const { patchPage } = await import("../src/human/index.js"); const childFrame: any = { click: vi.fn(async () => {}), dblclick: vi.fn(async () => {}), hover: vi.fn(async () => {}), type: vi.fn(async () => {}), fill: vi.fn(async () => {}), check: vi.fn(async () => {}), uncheck: vi.fn(async () => {}), selectOption: vi.fn(async () => {}), 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 })), })), 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); }); }); // ========================================================================= // Page-level: pressSequentially, tap, clear are patched // ========================================================================= describe("page-level pressSequentially, tap, clear patches", () => { it("page.pressSequentially is replaced after patchPage", async () => { const { patchPage } = await import("../src/human/index.js"); const page = buildMockPage(); const originalPressSeq = page.pressSequentially ?? (() => {}); const cfg = resolveConfig("default"); const cursor = { x: 0, y: 0, initialized: false }; patchPage(page as any, cfg, cursor as any); expect(typeof (page as any).pressSequentially).toBe("function"); expect((page as any).pressSequentially).not.toBe(originalPressSeq); }); it("page.tap is replaced after patchPage", async () => { const { patchPage } = await import("../src/human/index.js"); const page = buildMockPage(); const originalTap = page.tap ?? (() => {}); const cfg = resolveConfig("default"); const cursor = { x: 0, y: 0, initialized: false }; patchPage(page as any, cfg, cursor as any); expect(typeof (page as any).tap).toBe("function"); expect((page as any).tap).not.toBe(originalTap); }); it("page.clear is replaced after patchPage", async () => { const { patchPage } = await import("../src/human/index.js"); const page = buildMockPage(); const originalClear = page.clear ?? (() => {}); const cfg = resolveConfig("default"); const cursor = { x: 0, y: 0, initialized: false }; patchPage(page as any, cfg, cursor as any); expect(typeof (page as any).clear).toBe("function"); expect((page as any).clear).not.toBe(originalClear); }); }); // ========================================================================= // Frame-level: pressSequentially, tap are patched // ========================================================================= describe("frame-level pressSequentially, tap patches", () => { it("child frame has pressSequentially patched", async () => { const { patchPage } = await import("../src/human/index.js"); const childFrame: any = { click: vi.fn(async () => {}), dblclick: vi.fn(async () => {}), hover: vi.fn(async () => {}), type: vi.fn(async () => {}), fill: vi.fn(async () => {}), check: vi.fn(async () => {}), uncheck: vi.fn(async () => {}), selectOption: vi.fn(async () => {}), press: vi.fn(async () => {}), pressSequentially: vi.fn(async () => {}), tap: 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 })), })), childFrames: vi.fn(() => []), }; const origPressSeq = childFrame.pressSequentially; const origTap = childFrame.tap; 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); // pressSequentially and tap should be replaced with humanized versions expect(childFrame.pressSequentially).not.toBe(origPressSeq); expect(childFrame.tap).not.toBe(origTap); expect(typeof childFrame.pressSequentially).toBe("function"); expect(typeof childFrame.tap).toBe("function"); }); }); // ========================================================================= // Non-ASCII text does NOT go through CDP shift symbol path // ========================================================================= describe("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); // 'H' → shifted char via raw expect(downKeys).toContain("Shift"); expect(downKeys).toContain("H"); // 'i' → normal char expect(downKeys).toContain("i"); // '!' → CDP path const keyEvents = cdpCalls.filter(([m]) => m === "Input.dispatchKeyEvent"); expect(keyEvents.length).toBe(2); // ' ' → normal char (space) expect(downKeys).toContain(" "); // 'Мир' → insertText expect(insertedChars).toContain("М"); expect(insertedChars).toContain("и"); expect(insertedChars).toContain("р"); expect(page.evaluate).not.toHaveBeenCalled(); }); }); // ========================================================================= // 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("stealth browser: no evaluate leak on click", () => { it("click() does not trigger querySelector from evaluate context", async () => { const { launch } = await import("../src/index.js"); const browser = await launch({ headless: true, humanize: 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("stealth browser: shift symbols isTrusted=true", () => { it("'!' produces isTrusted=true keydown, not isTrusted=false", async () => { const { launch } = await import("../src/index.js"); const browser = await launch({ headless: true, humanize: 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("stealth browser: navigation invalidation", () => { it("click works after navigation (isolated world re-created)", async () => { const { launch } = await import("../src/index.js"); const browser = await launch({ headless: true, humanize: 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 — invalidates isolated world await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' }); await sleep(1000); // Should still work (isolated world auto re-created) await page.click('#searchInput'); await sleep(300); await page.keyboard.type('after navigation'); await sleep(500); const val = await page.locator('#searchInput').inputValue(); expect(val).toContain('after navigation'); await browser.close(); }, 60000); }); describeIfSlow("stealth browser: full form no evaluate leak", () => { it("form with shift symbols has zero evaluate leaks and zero untrusted events", async () => { const { launch } = await import("../src/index.js"); const browser = await launch({ headless: true, humanize: true }); const page = await browser.newPage(); await page.goto( 'https://deviceandbrowserinfo.com/are_you_a_bot_interactions', { waitUntil: 'domcontentloaded' }, ); await sleep(3000); await page.evaluate(() => { (window as any).__evalLeaks = []; (window as any).__untrustedKeys = []; 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; document.addEventListener('keydown', (e) => { if (!e.isTrusted) { (window as any).__untrustedKeys.push((e as KeyboardEvent).key); } }, true); }); await page.click('#email'); await sleep(300); await page.fill('#email', 'test@example.com'); await sleep(500); await page.click('#password'); await sleep(300); await page.fill('#password', 'SecurePass!@#123'); await sleep(500); const evalLeaks = await page.evaluate(() => (window as any).__evalLeaks || []); const untrusted = await page.evaluate(() => (window as any).__untrustedKeys || []); expect(evalLeaks.length).toBe(0); expect(untrusted.length).toBe(0); await page.click('button[type="submit"]'); await sleep(5000); const body = await page.locator('body').textContent(); expect(body).not.toContain('"superHumanSpeed": true'); expect(body).not.toContain('"suspiciousClientSideBehavior": true'); await browser.close(); }, 60000); });