diff --git a/packages/core/src/__tests__/client.test.ts b/packages/core/src/__tests__/client.test.ts index dcd4d57..b2d4d77 100644 --- a/packages/core/src/__tests__/client.test.ts +++ b/packages/core/src/__tests__/client.test.ts @@ -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) => { diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index beed1b2..761ac51 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -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) {} diff --git a/packages/daemon/src/__tests__/browser-manager.test.ts b/packages/daemon/src/__tests__/browser-manager.test.ts new file mode 100644 index 0000000..ee26057 --- /dev/null +++ b/packages/daemon/src/__tests__/browser-manager.test.ts @@ -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(); + }); +}); diff --git a/packages/daemon/src/__tests__/page-registry.test.ts b/packages/daemon/src/__tests__/page-registry.test.ts new file mode 100644 index 0000000..14a9ba4 --- /dev/null +++ b/packages/daemon/src/__tests__/page-registry.test.ts @@ -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 { + 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'])); + }); +}); diff --git a/packages/daemon/src/browser-manager.ts b/packages/daemon/src/browser-manager.ts new file mode 100644 index 0000000..0d26113 --- /dev/null +++ b/packages/daemon/src/browser-manager.ts @@ -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 { + this.browser = await chromium.launch({ + headless: true, + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-blink-features=AutomationControlled', + ], + }); + } + + async createPage(url: string, alias?: string): Promise { + 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 { + 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 { + // 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; + } + } +} diff --git a/packages/daemon/src/page-registry.ts b/packages/daemon/src/page-registry.ts new file mode 100644 index 0000000..8626d78 --- /dev/null +++ b/packages/daemon/src/page-registry.ts @@ -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(); + private aliasMap = new Map(); // 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); + } +}