1168 lines
40 KiB
TypeScript
1168 lines
40 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|