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:
@@ -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