Files
VisionL/packages/daemon/src/__tests__/stealth-human-input.test.ts
T
AskaEth 6085c9e65a feat(daemon): implement stealth human input simulation (Task 15)
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.
2026-08-12 21:16:37 +08:00

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);
});
});