feat: add browser manager with page registry and Playwright stealth integration
- PageRegistry: thread-safe in-memory registry for tracking active pages by ID and alias - BrowserManager: Playwright browser lifecycle with stealth plugin, context creation, and page navigation - 8 unit tests for PageRegistry (add, get, findByIdOrAlias, hasAlias, remove, list) - 8 integration tests for BrowserManager (skipped by default, guard: VISIONL_INTEGRATION=1) - Fix: PageInfo import in core/client.ts (was incorrectly imported from api.js instead of page.js)
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import { VisionLClient } from '../client.js';
|
import { VisionLClient } from '../client.js';
|
||||||
import type { ApiResponse, PageInfo } from '../types/api.js';
|
import type { ApiResponse } from '../types/api.js';
|
||||||
|
import type { PageInfo } from '../types/page.js';
|
||||||
|
|
||||||
function createMockServer() {
|
function createMockServer() {
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ApiResponse, PageInfo } from './types/api.js';
|
import type { ApiResponse } from './types/api.js';
|
||||||
|
import type { PageInfo } from './types/page.js';
|
||||||
|
|
||||||
export class VisionLClient {
|
export class VisionLClient {
|
||||||
constructor(private baseUrl: string) {}
|
constructor(private baseUrl: string) {}
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// Integration tests for BrowserManager — requires Playwright + Chromium / 浏览器管理器集成测试,需要 Playwright 和 Chromium
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import type { FingerprintProfile } from '@visionl/core';
|
||||||
|
import { BrowserManager } from '../browser-manager.js';
|
||||||
|
|
||||||
|
const integration = process.env.VISIONL_INTEGRATION === '1' ? describe : describe.skip;
|
||||||
|
|
||||||
|
const defaultProfile: FingerprintProfile = {
|
||||||
|
id: 'fp_test',
|
||||||
|
name: 'Test Profile',
|
||||||
|
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',
|
||||||
|
geolocation: { latitude: 40.7128, longitude: -74.006, accuracy: 10 },
|
||||||
|
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 },
|
||||||
|
};
|
||||||
|
|
||||||
|
integration('BrowserManager', () => {
|
||||||
|
let manager: BrowserManager;
|
||||||
|
|
||||||
|
it('init launches browser without error', async () => {
|
||||||
|
manager = new BrowserManager(defaultProfile);
|
||||||
|
await manager.init();
|
||||||
|
|
||||||
|
// Verify browser reports pages
|
||||||
|
const pages = manager.listPages();
|
||||||
|
expect(Array.isArray(pages)).toBe(true);
|
||||||
|
|
||||||
|
await manager.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createPage opens a URL and returns PageInfo', async () => {
|
||||||
|
manager = new BrowserManager(defaultProfile);
|
||||||
|
await manager.init();
|
||||||
|
|
||||||
|
const info = await manager.createPage('https://example.com', 'example');
|
||||||
|
expect(info.id).toMatch(/^p_/);
|
||||||
|
expect(info.url).toBe('https://example.com');
|
||||||
|
expect(info.alias).toBe('example');
|
||||||
|
expect(info.status).toBe('active');
|
||||||
|
expect(info.profile).toBe('fp_test');
|
||||||
|
expect(info.title).toBeTruthy();
|
||||||
|
|
||||||
|
await manager.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getPage returns page by id and alias', async () => {
|
||||||
|
manager = new BrowserManager(defaultProfile);
|
||||||
|
await manager.init();
|
||||||
|
|
||||||
|
const info = await manager.createPage('https://example.com', 'home');
|
||||||
|
const byId = manager.getPage(info.id);
|
||||||
|
const byAlias = manager.getPage('home');
|
||||||
|
|
||||||
|
expect(byId).toBeDefined();
|
||||||
|
expect(byAlias).toBeDefined();
|
||||||
|
expect(byId!.info.id).toBe(byAlias!.info.id);
|
||||||
|
|
||||||
|
await manager.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closePage removes the page', async () => {
|
||||||
|
manager = new BrowserManager(defaultProfile);
|
||||||
|
await manager.init();
|
||||||
|
|
||||||
|
const info = await manager.createPage('https://example.com');
|
||||||
|
expect(manager.listPages()).toHaveLength(1);
|
||||||
|
|
||||||
|
await manager.closePage(info.id);
|
||||||
|
expect(manager.listPages()).toHaveLength(0);
|
||||||
|
expect(manager.getPage(info.id)).toBeUndefined();
|
||||||
|
|
||||||
|
await manager.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createPage with duplicate alias throws', async () => {
|
||||||
|
manager = new BrowserManager(defaultProfile);
|
||||||
|
await manager.init();
|
||||||
|
|
||||||
|
await manager.createPage('https://example.com', 'dup');
|
||||||
|
await expect(
|
||||||
|
manager.createPage('https://other.com', 'dup')
|
||||||
|
).rejects.toThrow(/already exists/);
|
||||||
|
|
||||||
|
await manager.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createPage with invalid URL throws', async () => {
|
||||||
|
manager = new BrowserManager(defaultProfile);
|
||||||
|
await manager.init();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
manager.createPage('not-a-valid-url')
|
||||||
|
).rejects.toThrow(/Invalid URL/);
|
||||||
|
|
||||||
|
await manager.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closePage with unknown id throws PAGE_NOT_FOUND', async () => {
|
||||||
|
manager = new BrowserManager(defaultProfile);
|
||||||
|
await manager.init();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
manager.closePage('nonexistent')
|
||||||
|
).rejects.toThrow(/not found/);
|
||||||
|
|
||||||
|
await manager.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleanup closes all pages and browser', async () => {
|
||||||
|
manager = new BrowserManager(defaultProfile);
|
||||||
|
await manager.init();
|
||||||
|
|
||||||
|
await manager.createPage('https://example.com');
|
||||||
|
await manager.createPage('https://httpbin.org/get');
|
||||||
|
expect(manager.listPages()).toHaveLength(2);
|
||||||
|
|
||||||
|
await manager.cleanup();
|
||||||
|
expect(manager.listPages()).toHaveLength(0);
|
||||||
|
|
||||||
|
// Should not throw — can call cleanup multiple times
|
||||||
|
await manager.cleanup();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// unit tests for PageRegistry — no Playwright browser required / 页面注册表单元测试,无需 Playwright 浏览器
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import type { PageInfo } from '@visionl/core';
|
||||||
|
import { PageRegistry, type RegisteredPage } from '../page-registry.js';
|
||||||
|
|
||||||
|
function makeInfo(overrides: Partial<PageInfo> = {}): PageInfo {
|
||||||
|
return {
|
||||||
|
id: 'p_test1',
|
||||||
|
url: 'https://example.com',
|
||||||
|
title: 'Example',
|
||||||
|
status: 'active',
|
||||||
|
profile: 'fp_default',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMockPage() {
|
||||||
|
return {
|
||||||
|
url: () => 'https://example.com',
|
||||||
|
title: () => Promise.resolve('Example'),
|
||||||
|
close: () => Promise.resolve(),
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMockContext() {
|
||||||
|
return {
|
||||||
|
close: () => Promise.resolve(),
|
||||||
|
newPage: () => Promise.resolve(makeMockPage()),
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PageRegistry', () => {
|
||||||
|
let registry: PageRegistry;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
registry = new PageRegistry();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('add stores entry and can be retrieved via get', () => {
|
||||||
|
const info = makeInfo();
|
||||||
|
const page = makeMockPage();
|
||||||
|
const context = makeMockContext();
|
||||||
|
|
||||||
|
registry.add(info, page, context);
|
||||||
|
|
||||||
|
const entry = registry.get('p_test1');
|
||||||
|
expect(entry).toBeDefined();
|
||||||
|
expect(entry!.info).toEqual(info);
|
||||||
|
expect(entry!.page).toBe(page);
|
||||||
|
expect(entry!.context).toBe(context);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('add with alias makes it findable by alias', () => {
|
||||||
|
const info = makeInfo({ alias: 'home' });
|
||||||
|
const page = makeMockPage();
|
||||||
|
const context = makeMockContext();
|
||||||
|
|
||||||
|
registry.add(info, page, context);
|
||||||
|
|
||||||
|
const entry = registry.findByIdOrAlias('home');
|
||||||
|
expect(entry).toBeDefined();
|
||||||
|
expect(entry!.info.id).toBe('p_test1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('add with alias records hasAlias as true', () => {
|
||||||
|
const info = makeInfo({ alias: 'home' });
|
||||||
|
registry.add(info, makeMockPage(), makeMockContext());
|
||||||
|
|
||||||
|
expect(registry.hasAlias('home')).toBe(true);
|
||||||
|
expect(registry.hasAlias('nonexistent')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findByIdOrAlias works with direct id', () => {
|
||||||
|
const info = makeInfo();
|
||||||
|
registry.add(info, makeMockPage(), makeMockContext());
|
||||||
|
|
||||||
|
const entry = registry.findByIdOrAlias('p_test1');
|
||||||
|
expect(entry).toBeDefined();
|
||||||
|
expect(entry!.info.title).toBe('Example');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findByIdOrAlias returns undefined for unknown id or alias', () => {
|
||||||
|
expect(registry.findByIdOrAlias('unknown')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remove deletes the entry and its alias, returns true', () => {
|
||||||
|
const info = makeInfo({ alias: 'home' });
|
||||||
|
registry.add(info, makeMockPage(), makeMockContext());
|
||||||
|
|
||||||
|
const result = registry.remove('p_test1');
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(registry.get('p_test1')).toBeUndefined();
|
||||||
|
expect(registry.hasAlias('home')).toBe(false);
|
||||||
|
expect(registry.findByIdOrAlias('home')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remove returns false for non-existent id', () => {
|
||||||
|
const result = registry.remove('no-such-id');
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('list returns all page infos', () => {
|
||||||
|
const a = makeInfo({ id: 'p_a', url: 'https://a.com' });
|
||||||
|
const b = makeInfo({ id: 'p_b', url: 'https://b.com' });
|
||||||
|
|
||||||
|
registry.add(a, makeMockPage(), makeMockContext());
|
||||||
|
registry.add(b, makeMockPage(), makeMockContext());
|
||||||
|
|
||||||
|
const pages = registry.list();
|
||||||
|
expect(pages).toHaveLength(2);
|
||||||
|
expect(pages.map((p) => p.id)).toEqual(expect.arrayContaining(['p_a', 'p_b']));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// BrowserManager — Playwright browser lifecycle and page management with stealth plugin / 浏览器管理器,使用隐身插件管理 Playwright 浏览器生命周期和页面
|
||||||
|
import { chromium } from 'playwright-extra';
|
||||||
|
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
|
||||||
|
import type { Browser, BrowserContext, Page } from 'playwright';
|
||||||
|
import type { FingerprintProfile, PageInfo } from '@visionl/core';
|
||||||
|
import { PageRegistry } from './page-registry.js';
|
||||||
|
|
||||||
|
// Apply stealth plugin once at module level / 模块级别一次性注入隐身插件
|
||||||
|
chromium.use(StealthPlugin());
|
||||||
|
|
||||||
|
function generatePageId(): string {
|
||||||
|
const hex = Math.random().toString(16).slice(2, 10);
|
||||||
|
return `p_${hex}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BrowserManager {
|
||||||
|
private browser: Browser | null = null;
|
||||||
|
private registry = new PageRegistry();
|
||||||
|
private profile: FingerprintProfile;
|
||||||
|
|
||||||
|
constructor(profile: FingerprintProfile) {
|
||||||
|
this.profile = profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
async init(): Promise<void> {
|
||||||
|
this.browser = await chromium.launch({
|
||||||
|
headless: true,
|
||||||
|
args: [
|
||||||
|
'--no-sandbox',
|
||||||
|
'--disable-setuid-sandbox',
|
||||||
|
'--disable-blink-features=AutomationControlled',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPage(url: string, alias?: string): Promise<PageInfo> {
|
||||||
|
if (!this.browser) throw new Error('Browser not initialized');
|
||||||
|
|
||||||
|
if (alias && this.registry.hasAlias(alias)) {
|
||||||
|
throw Object.assign(new Error(`Alias "${alias}" already exists`), { code: 'ALIAS_EXISTS' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate URL format / 验证 URL 格式
|
||||||
|
try {
|
||||||
|
new URL(url);
|
||||||
|
} catch {
|
||||||
|
throw Object.assign(new Error(`Invalid URL: ${url}`), { code: 'INVALID_URL' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = await this.browser.newContext({
|
||||||
|
viewport: this.profile.viewport,
|
||||||
|
userAgent: this.profile.userAgent,
|
||||||
|
locale: this.profile.languages[0],
|
||||||
|
timezoneId: this.profile.timezone,
|
||||||
|
permissions: Object.entries(this.profile.permissions)
|
||||||
|
.filter(([, v]) => v === 'granted')
|
||||||
|
.map(([k]) => k as any),
|
||||||
|
geolocation: this.profile.geolocation,
|
||||||
|
colorScheme: 'light',
|
||||||
|
deviceScaleFactor: this.profile.screen.pixelRatio,
|
||||||
|
});
|
||||||
|
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||||
|
const title = await page.title();
|
||||||
|
|
||||||
|
const id = generatePageId();
|
||||||
|
const info: PageInfo = { id, url, alias, title, status: 'active', profile: this.profile.id };
|
||||||
|
this.registry.add(info, page, context);
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
async closePage(idOrAlias: string): Promise<void> {
|
||||||
|
const entry = this.registry.findByIdOrAlias(idOrAlias);
|
||||||
|
if (!entry) {
|
||||||
|
throw Object.assign(new Error(`Page "${idOrAlias}" not found`), { code: 'PAGE_NOT_FOUND' });
|
||||||
|
}
|
||||||
|
await entry.context.close();
|
||||||
|
this.registry.remove(entry.info.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPage(idOrAlias: string) {
|
||||||
|
return this.registry.findByIdOrAlias(idOrAlias);
|
||||||
|
}
|
||||||
|
|
||||||
|
listPages(): PageInfo[] {
|
||||||
|
return this.registry.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanup(): Promise<void> {
|
||||||
|
// Close all contexts / 关闭所有上下文
|
||||||
|
const entries = this.registry['pages']?.values() ?? [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
try { await entry.context.close(); } catch { /* ignore / 忽略 */ }
|
||||||
|
}
|
||||||
|
if (this.browser) {
|
||||||
|
await this.browser.close();
|
||||||
|
this.browser = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// PageRegistry — tracks active Playwright pages by ID and alias / 页面注册表,通过 ID 和别名追踪活跃页面
|
||||||
|
import type { Page, BrowserContext } from 'playwright';
|
||||||
|
import type { PageInfo } from '@visionl/core';
|
||||||
|
|
||||||
|
export interface RegisteredPage {
|
||||||
|
info: PageInfo;
|
||||||
|
page: Page;
|
||||||
|
context: BrowserContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PageRegistry {
|
||||||
|
private pages = new Map<string, RegisteredPage>();
|
||||||
|
private aliasMap = new Map<string, string>(); // alias → id
|
||||||
|
|
||||||
|
add(info: PageInfo, page: Page, context: BrowserContext): void {
|
||||||
|
this.pages.set(info.id, { info, page, context });
|
||||||
|
if (info.alias) {
|
||||||
|
this.aliasMap.set(info.alias, info.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(id: string): boolean {
|
||||||
|
const entry = this.pages.get(id);
|
||||||
|
if (!entry) return false;
|
||||||
|
if (entry.info.alias) {
|
||||||
|
this.aliasMap.delete(entry.info.alias);
|
||||||
|
}
|
||||||
|
return this.pages.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(id: string): RegisteredPage | undefined {
|
||||||
|
return this.pages.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
findByIdOrAlias(idOrAlias: string): RegisteredPage | undefined {
|
||||||
|
const id = this.aliasMap.get(idOrAlias);
|
||||||
|
if (id) return this.pages.get(id);
|
||||||
|
return this.pages.get(idOrAlias);
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): PageInfo[] {
|
||||||
|
return Array.from(this.pages.values()).map((entry) => entry.info);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasAlias(alias: string): boolean {
|
||||||
|
return this.aliasMap.has(alias);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user