6085c9e65a
Add humanClick, humanType, humanScroll with mouse path interpolation, random key delays, and step-based scrolling for bot-like behavior. Includes integration tests for event sequence verification.
479 lines
17 KiB
TypeScript
479 lines
17 KiB
TypeScript
// Integration tests for humanClick, humanType, humanScroll / 仿人类输入模拟集成测试
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import { chromium } from 'playwright-extra';
|
|
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
|
|
import type { Browser, Page } from 'playwright';
|
|
import type { FingerprintProfile } from '@visionl/core';
|
|
import { humanClick, humanType, humanScroll, randBetween } from '../stealth/human-input.js';
|
|
|
|
chromium.use(StealthPlugin());
|
|
|
|
const integration =
|
|
process.env.VISIONL_INTEGRATION === '1' ? describe : describe.skip;
|
|
|
|
const testProfile: FingerprintProfile = {
|
|
id: 'fp_human_input',
|
|
name: 'Human Input Test',
|
|
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
platform: 'Win32',
|
|
languages: ['en-US', 'en'],
|
|
acceptLanguage: 'en-US,en;q=0.9',
|
|
screen: { width: 1920, height: 1080, colorDepth: 24, pixelRatio: 1 },
|
|
viewport: { width: 1280, height: 720 },
|
|
webgl: { vendor: 'Google Inc.', renderer: 'ANGLE (NVIDIA GeForce RTX 3060)' },
|
|
timezone: 'America/New_York',
|
|
permissions: {
|
|
notifications: 'denied',
|
|
geolocation: 'granted',
|
|
camera: 'denied',
|
|
microphone: 'denied',
|
|
},
|
|
behavior: {
|
|
mouseMoveDelay: { min: 50, max: 150 },
|
|
keyPressDelay: { min: 80, max: 200 },
|
|
scrollStepDelay: { min: 30, max: 100 },
|
|
},
|
|
canvasNoise: { enabled: true, strength: 0.5 },
|
|
};
|
|
|
|
async function installEventTracker(page: Page): Promise<void> {
|
|
await page.evaluate(() => {
|
|
const recorded: Array<{ type: string; timestamp: number }> = [];
|
|
(window as any).__vlEvents = recorded;
|
|
|
|
const track = (e: Event) => {
|
|
recorded.push({ type: e.type, timestamp: Date.now() });
|
|
};
|
|
|
|
document.addEventListener('mousemove', track, true);
|
|
document.addEventListener('mousedown', track, true);
|
|
document.addEventListener('mouseup', track, true);
|
|
document.addEventListener('click', track, true);
|
|
document.addEventListener('keydown', track, true);
|
|
document.addEventListener('keypress', track, true);
|
|
document.addEventListener('keyup', track, true);
|
|
document.addEventListener('input', track, true);
|
|
document.addEventListener('wheel', track, true);
|
|
document.addEventListener('scroll', track, true);
|
|
});
|
|
}
|
|
|
|
async function getRecordedEvents(page: Page): Promise<Array<{ type: string; timestamp: number }>> {
|
|
return page.evaluate(() => (window as any).__vlEvents || []);
|
|
}
|
|
|
|
// Helper: start a simple page with a button and an input / 启动带按钮和输入框的简单页面
|
|
async function setupTestPage(page: Page): Promise<void> {
|
|
await page.setContent(`
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head><title>Human Input Test</title></head>
|
|
<body style="height:3000px;">
|
|
<button id="target-btn" style="margin:100px;padding:20px;">Click Me</button>
|
|
<input id="target-input" type="text" value="" style="margin:100px;padding:10px;font-size:16px;">
|
|
<input id="target-input2" type="text" value="prefill" style="margin:100px;">
|
|
<div id="bottom-marker" style="margin-top:2800px;">Bottom</div>
|
|
</body>
|
|
</html>
|
|
`);
|
|
await page.waitForSelector('#target-btn', { state: 'visible' });
|
|
await installEventTracker(page);
|
|
}
|
|
|
|
// ==================== humanClick tests / humanClick 测试 ====================
|
|
integration('humanClick', () => {
|
|
let browser: Browser;
|
|
let page: Page;
|
|
|
|
beforeAll(async () => {
|
|
browser = await chromium.launch({
|
|
headless: true,
|
|
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
});
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (browser) await browser.close();
|
|
});
|
|
|
|
it('should dispatch mouse event sequence: mousemove → mousedown → mouseup → click', async () => {
|
|
const context = await browser.newContext();
|
|
page = await context.newPage();
|
|
await setupTestPage(page);
|
|
|
|
await humanClick(page, '#target-btn', testProfile);
|
|
// Give async event handling a moment / 等待异步事件处理完成
|
|
await page.waitForTimeout(200);
|
|
|
|
const events = await getRecordedEvents(page);
|
|
|
|
// Check event types exist in order / 验证事件类型存在且顺序正确
|
|
const eventTypes = events.map((e) => e.type);
|
|
|
|
const hasMouseMove = eventTypes.some((t) => t === 'mousemove');
|
|
const hasMouseDown = eventTypes.some((t) => t === 'mousedown');
|
|
const hasMouseUp = eventTypes.some((t) => t === 'mouseup');
|
|
const hasClick = eventTypes.some((t) => t === 'click');
|
|
|
|
expect(hasMouseMove).toBe(true);
|
|
expect(hasMouseDown).toBe(true);
|
|
expect(hasMouseUp).toBe(true);
|
|
expect(hasClick).toBe(true);
|
|
|
|
// Verify order: mousemove before mousedown, mousedown before mouseup, mouseup before click
|
|
// 验证事件顺序
|
|
const moveIdx = eventTypes.indexOf('mousemove');
|
|
const downIdx = eventTypes.indexOf('mousedown');
|
|
const upIdx = eventTypes.indexOf('mouseup');
|
|
const clickIdx = eventTypes.indexOf('click');
|
|
|
|
expect(moveIdx).toBeLessThan(downIdx);
|
|
expect(downIdx).toBeLessThan(upIdx);
|
|
expect(upIdx).toBeLessThan(clickIdx);
|
|
|
|
await context.close();
|
|
});
|
|
|
|
it('should produce multiple mousemove events along the path', async () => {
|
|
const context = await browser.newContext();
|
|
page = await context.newPage();
|
|
await setupTestPage(page);
|
|
|
|
await humanClick(page, '#target-btn', testProfile);
|
|
await page.waitForTimeout(200);
|
|
|
|
const events = await getRecordedEvents(page);
|
|
const moveEvents = events.filter((e) => e.type === 'mousemove');
|
|
|
|
// Should have at least 2 mousemove events (start + approach) / 至少2个 mousemove 事件
|
|
expect(moveEvents.length).toBeGreaterThanOrEqual(2);
|
|
|
|
await context.close();
|
|
});
|
|
|
|
it('should throw for non-existent selector / 不存在选择器应报错', async () => {
|
|
const context = await browser.newContext();
|
|
page = await context.newPage();
|
|
await setupTestPage(page);
|
|
|
|
await expect(
|
|
humanClick(page, '#non-existent-element', testProfile),
|
|
).rejects.toThrow(/element not found/i);
|
|
|
|
await context.close();
|
|
});
|
|
|
|
it('should click within element bounds / 应在元素范围内点击', async () => {
|
|
const context = await browser.newContext();
|
|
page = await context.newPage();
|
|
await setupTestPage(page);
|
|
|
|
// Track click coordinates / 记录点击坐标
|
|
const clickCoords = await page.evaluate(() => {
|
|
return new Promise<{ x: number; y: number }>((resolve) => {
|
|
const btn = document.getElementById('target-btn')!;
|
|
btn.addEventListener('click', (e) => {
|
|
resolve({ x: (e as MouseEvent).clientX, y: (e as MouseEvent).clientY });
|
|
}, { once: true });
|
|
});
|
|
});
|
|
|
|
const box = await page.locator('#target-btn').boundingBox();
|
|
expect(box).not.toBeNull();
|
|
|
|
// Trigger humanClick and capture coordinates / 触发点击并捕获坐标
|
|
await humanClick(page, '#target-btn', testProfile);
|
|
const coords = await clickCoords;
|
|
|
|
// Click should be within element bounds (±10px tolerance for jitter) / 点击应在元素范围内
|
|
expect(coords.x).toBeGreaterThanOrEqual(box!.x - 10);
|
|
expect(coords.x).toBeLessThanOrEqual(box!.x + box!.width + 10);
|
|
expect(coords.y).toBeGreaterThanOrEqual(box!.y - 10);
|
|
expect(coords.y).toBeLessThanOrEqual(box!.y + box!.height + 10);
|
|
|
|
await context.close();
|
|
});
|
|
});
|
|
|
|
// ==================== humanType tests / humanType 测试 ====================
|
|
integration('humanType', () => {
|
|
let browser: Browser;
|
|
let page: Page;
|
|
|
|
beforeAll(async () => {
|
|
browser = await chromium.launch({
|
|
headless: true,
|
|
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
});
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (browser) await browser.close();
|
|
});
|
|
|
|
it('should type text into the input element', async () => {
|
|
const context = await browser.newContext();
|
|
page = await context.newPage();
|
|
await setupTestPage(page);
|
|
|
|
await humanType(page, '#target-input', 'Hello', testProfile);
|
|
await page.waitForTimeout(300);
|
|
|
|
const value = await page.locator('#target-input').inputValue();
|
|
expect(value).toBe('Hello');
|
|
|
|
await context.close();
|
|
});
|
|
|
|
it('should dispatch keydown → keypress → keyup for each character / 每个字符应触发 keydown → keypress → keyup', async () => {
|
|
const context = await browser.newContext();
|
|
page = await context.newPage();
|
|
await setupTestPage(page);
|
|
|
|
await humanType(page, '#target-input', 'AB', testProfile);
|
|
await page.waitForTimeout(300);
|
|
|
|
const events = await getRecordedEvents(page);
|
|
const eventTypes = events.map((e) => e.type);
|
|
|
|
const keydownCount = eventTypes.filter((t) => t === 'keydown').length;
|
|
const keypressCount = eventTypes.filter((t) => t === 'keypress').length;
|
|
const keyupCount = eventTypes.filter((t) => t === 'keyup').length;
|
|
|
|
// Each character should produce at least 1 of each event / 每个字符至少产生1个事件
|
|
expect(keydownCount).toBeGreaterThanOrEqual(2);
|
|
expect(keypressCount).toBeGreaterThanOrEqual(2);
|
|
expect(keyupCount).toBeGreaterThanOrEqual(2);
|
|
|
|
await context.close();
|
|
});
|
|
|
|
it('should clear pre-existing text before typing / 应在输入前清除已有文本', async () => {
|
|
const context = await browser.newContext();
|
|
page = await context.newPage();
|
|
await setupTestPage(page);
|
|
|
|
// Pre-fill the input / 预填充输入框
|
|
await page.locator('#target-input2').fill('oldvalue');
|
|
const beforeVal = await page.locator('#target-input2').inputValue();
|
|
expect(beforeVal).toBe('oldvalue');
|
|
|
|
await humanType(page, '#target-input2', 'New', testProfile);
|
|
await page.waitForTimeout(300);
|
|
|
|
const afterVal = await page.locator('#target-input2').inputValue();
|
|
expect(afterVal).toBe('New');
|
|
|
|
await context.close();
|
|
});
|
|
|
|
it('should produce inter-character delays within configured range / 字符间延迟应在配置范围内', async () => {
|
|
const context = await browser.newContext({ viewport: { width: 1280, height: 720 } });
|
|
page = await context.newPage();
|
|
await setupTestPage(page);
|
|
|
|
// Track key events with high-res timestamps / 用高精度时间戳跟踪按键事件
|
|
const keyTimestamps = await page.evaluate(() => {
|
|
return new Promise<number[]>((resolve) => {
|
|
const timestamps: number[] = [];
|
|
const input = document.getElementById('target-input')!;
|
|
input.addEventListener('keydown', () => {
|
|
timestamps.push(performance.now());
|
|
});
|
|
// Resolve after all characters processed / 所有字符处理后结束
|
|
const observer = new MutationObserver(() => {
|
|
const val = (input as HTMLInputElement).value;
|
|
if (val.length >= 3) {
|
|
observer.disconnect();
|
|
// Give a little more time for the last events / 给最后的事件留时间
|
|
setTimeout(() => resolve(timestamps), 100);
|
|
}
|
|
});
|
|
observer.observe(input, { attributes: true, attributeFilter: ['value'] });
|
|
});
|
|
});
|
|
|
|
// Type a short string and capture timestamps / 输入短字符串并捕获时间戳
|
|
const typePromise = humanType(page, '#target-input', 'ABC', testProfile);
|
|
const timestamps = await keyTimestamps;
|
|
await typePromise;
|
|
|
|
// Calculate inter-key delays / 计算按键间延迟
|
|
expect(timestamps.length).toBeGreaterThanOrEqual(3);
|
|
|
|
// Verify each inter-key delay is reasonable (> 0ms, < 1000ms)
|
|
// 验证每个按键间延迟合理
|
|
for (let i = 1; i < timestamps.length; i++) {
|
|
const delay = timestamps[i] - timestamps[i - 1];
|
|
expect(delay).toBeGreaterThan(0);
|
|
// Should be within a reasonable upper bound given the profile
|
|
// 考虑配置范围内应有合理上限
|
|
expect(delay).toBeLessThan(1000);
|
|
}
|
|
|
|
await context.close();
|
|
});
|
|
|
|
it('should handle empty string gracefully / 应优雅处理空字符串', async () => {
|
|
const context = await browser.newContext();
|
|
page = await context.newPage();
|
|
await setupTestPage(page);
|
|
|
|
await humanType(page, '#target-input', '', testProfile);
|
|
await page.waitForTimeout(200);
|
|
|
|
const value = await page.locator('#target-input').inputValue();
|
|
// After clearing, the input should be empty / 清除后输入框应为空
|
|
expect(value).toBe('');
|
|
|
|
await context.close();
|
|
});
|
|
});
|
|
|
|
// ==================== humanScroll tests / humanScroll 测试 ====================
|
|
integration('humanScroll', () => {
|
|
let browser: Browser;
|
|
let page: Page;
|
|
|
|
beforeAll(async () => {
|
|
browser = await chromium.launch({
|
|
headless: true,
|
|
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
});
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (browser) await browser.close();
|
|
});
|
|
|
|
it('should dispatch scroll events / 应触发 scroll 事件', async () => {
|
|
const context = await browser.newContext();
|
|
page = await context.newPage();
|
|
await setupTestPage(page);
|
|
|
|
await humanScroll(page, { deltaY: 300 }, testProfile);
|
|
await page.waitForTimeout(300);
|
|
|
|
const events = await getRecordedEvents(page);
|
|
const scrollEvents = events.filter((e) => e.type === 'scroll');
|
|
// Should have at least one scroll event / 至少有一个 scroll 事件
|
|
expect(scrollEvents.length).toBeGreaterThanOrEqual(1);
|
|
|
|
await context.close();
|
|
});
|
|
|
|
it('should change scroll position with deltaY / deltaY 应改变滚动位置', async () => {
|
|
const context = await browser.newContext();
|
|
page = await context.newPage();
|
|
await setupTestPage(page);
|
|
|
|
const beforeY = await page.evaluate(() => window.scrollY);
|
|
await humanScroll(page, { deltaY: 400 }, testProfile);
|
|
await page.waitForTimeout(300);
|
|
const afterY = await page.evaluate(() => window.scrollY);
|
|
|
|
// Should scroll down / 应向下滚动
|
|
expect(afterY).toBeGreaterThan(beforeY);
|
|
|
|
await context.close();
|
|
});
|
|
|
|
it('should scroll to bottom with toBottom option / toBottom 选项应滚动到底', async () => {
|
|
const context = await browser.newContext();
|
|
page = await context.newPage();
|
|
await setupTestPage(page);
|
|
|
|
await humanScroll(page, { toBottom: true }, testProfile);
|
|
await page.waitForTimeout(500);
|
|
|
|
const atBottom = await page.evaluate(() => {
|
|
return window.innerHeight + window.scrollY >= document.body.scrollHeight - 5;
|
|
});
|
|
expect(atBottom).toBe(true);
|
|
|
|
await context.close();
|
|
});
|
|
|
|
it('should dispatch wheel events for realism / 应触发 wheel 事件以实现真实性', async () => {
|
|
const context = await browser.newContext();
|
|
page = await context.newPage();
|
|
await setupTestPage(page);
|
|
|
|
await humanScroll(page, { deltaY: 200 }, testProfile);
|
|
await page.waitForTimeout(300);
|
|
|
|
const events = await getRecordedEvents(page);
|
|
const wheelEvents = events.filter((e) => e.type === 'wheel');
|
|
// Wheel events simulate real user scrolling / wheel 事件模拟真实用户滚动
|
|
expect(wheelEvents.length).toBeGreaterThanOrEqual(1);
|
|
|
|
await context.close();
|
|
});
|
|
|
|
it('should not error when toBottom on short page / toBottom 在短页面不应报错', async () => {
|
|
const context = await browser.newContext();
|
|
page = await context.newPage();
|
|
await page.setContent(`<html><body style="height:200px;"><p>Short page</p></body></html>`);
|
|
await page.waitForLoadState('domcontentloaded');
|
|
await installEventTracker(page);
|
|
|
|
// Should complete without throwing / 应无错误完成
|
|
await expect(
|
|
humanScroll(page, { toBottom: true }, testProfile),
|
|
).resolves.toBeUndefined();
|
|
|
|
await context.close();
|
|
});
|
|
|
|
it('should scroll in multiple steps for large deltas / 大增量应分步滚动', async () => {
|
|
const context = await browser.newContext();
|
|
page = await context.newPage();
|
|
await setupTestPage(page);
|
|
|
|
// Large delta should be broken into chunks / 大增量应分块
|
|
await humanScroll(page, { deltaY: 800 }, testProfile);
|
|
await page.waitForTimeout(500);
|
|
|
|
const events = await getRecordedEvents(page);
|
|
const wheelEvents = events.filter((e) => e.type === 'wheel');
|
|
// Should have multiple wheel events for large scroll / 大滚动应有多个 wheel 事件
|
|
expect(wheelEvents.length).toBeGreaterThanOrEqual(2);
|
|
|
|
await context.close();
|
|
});
|
|
});
|
|
|
|
// ==================== randBetween unit tests / randBetween 单元测试 ====================
|
|
describe('randBetween', () => {
|
|
it('should return a value within [min, max] range / 应在 [min, max] 范围内返回值', () => {
|
|
for (let i = 0; i < 100; i++) {
|
|
const val = randBetween(10, 20);
|
|
expect(val).toBeGreaterThanOrEqual(10);
|
|
expect(val).toBeLessThanOrEqual(20);
|
|
}
|
|
});
|
|
|
|
it('should return an integer / 应返回整数', () => {
|
|
for (let i = 0; i < 50; i++) {
|
|
const val = randBetween(0, 100);
|
|
expect(Number.isInteger(val)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('should return min when min === max / min 等于 max 应返回该值', () => {
|
|
for (let i = 0; i < 10; i++) {
|
|
expect(randBetween(5, 5)).toBe(5);
|
|
}
|
|
});
|
|
|
|
it('should produce varied values over many calls / 多次调用产生不同值', () => {
|
|
const values = new Set<number>();
|
|
for (let i = 0; i < 50; i++) {
|
|
values.add(randBetween(1, 100));
|
|
}
|
|
// With 100 possible values and 50 calls, expect at least some variation
|
|
// 100种可能值、50次调用,至少应有变化
|
|
expect(values.size).toBeGreaterThan(1);
|
|
});
|
|
});
|