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:
2026-08-12 20:51:08 +08:00
parent 513da621c6
commit a8fb781a7c
6 changed files with 407 additions and 2 deletions
+2 -1
View File
@@ -1,7 +1,8 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import http from 'node:http';
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() {
const server = http.createServer((req, res) => {
+2 -1
View File
@@ -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 {
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']));
});
});
+102
View File
@@ -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;
}
}
}
+48
View File
@@ -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);
}
}