// 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 { 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> { return page.evaluate(() => (window as any).__vlEvents || []); } // Helper: start a simple page with a button and an input / 启动带按钮和输入框的简单页面 async function setupTestPage(page: Page): Promise { await page.setContent(` Human Input Test
Bottom
`); 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((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(`

Short page

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