Files
wehub-resource-sync 0af812490d
CI / python (push) Failing after 0s
CI / javascript (push) Failing after 1s
CI / dotnet (push) Failing after 1s
chore: import upstream snapshot with attribution
2026-07-13 12:12:36 +08:00

1168 lines
40 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 (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<string, any> = {}): 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<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 };
}
// =========================================================================
// 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);
});