/** * Browser lifecycle manager * * Chromium crash handling: * browser.on('disconnected') → log error → process.exit(1) * CLI detects dead server → auto-restarts on next command * We do NOT try to self-heal — don't hide failure. */ import { chromium, type Browser, type BrowserContext, type Page, type Locator } from 'playwright'; import { addConsoleEntry, addNetworkEntry, networkBuffer, type LogEntry, type NetworkEntry } from './buffers'; export class BrowserManager { private browser: Browser | null = null; private context: BrowserContext | null = null; private pages: Map = new Map(); private activeTabId: number = 0; private nextTabId: number = 1; private extraHeaders: Record = {}; private customUserAgent: string | null = null; // ─── Ref Map (snapshot → @e1, @e2, ...) ──────────────────── private refMap: Map = new Map(); async launch() { this.browser = await chromium.launch({ headless: true }); // Chromium crash → exit with clear message this.browser.on('disconnected', () => { console.error('[browse] FATAL: Chromium process crashed or was killed. Server exiting.'); console.error('[browse] Console/network logs flushed to /tmp/browse-*.log'); process.exit(1); }); this.context = await this.browser.newContext({ viewport: { width: 1280, height: 720 }, }); // Create first tab await this.newTab(); } async close() { if (this.browser) { // Remove disconnect handler to avoid exit during intentional close this.browser.removeAllListeners('disconnected'); await this.browser.close(); this.browser = null; } } isHealthy(): boolean { return this.browser !== null && this.browser.isConnected(); } // ─── Tab Management ──────────────────────────────────────── async newTab(url?: string): Promise { if (!this.context) throw new Error('Browser not launched'); const page = await this.context.newPage(); const id = this.nextTabId++; this.pages.set(id, page); this.activeTabId = id; // Wire up console/network capture this.wirePageEvents(page); if (url) { await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 }); } return id; } async closeTab(id?: number): Promise { const tabId = id ?? this.activeTabId; const page = this.pages.get(tabId); if (!page) throw new Error(`Tab ${tabId} not found`); await page.close(); this.pages.delete(tabId); // Switch to another tab if we closed the active one if (tabId === this.activeTabId) { const remaining = [...this.pages.keys()]; if (remaining.length > 0) { this.activeTabId = remaining[remaining.length - 1]; } else { // No tabs left — create a new blank one await this.newTab(); } } } switchTab(id: number): void { if (!this.pages.has(id)) throw new Error(`Tab ${id} not found`); this.activeTabId = id; } getTabCount(): number { return this.pages.size; } getTabList(): Array<{ id: number; url: string; title: string; active: boolean }> { const tabs: Array<{ id: number; url: string; title: string; active: boolean }> = []; for (const [id, page] of this.pages) { tabs.push({ id, url: page.url(), title: '', // title requires await, populated by caller active: id === this.activeTabId, }); } return tabs; } async getTabListWithTitles(): Promise> { const tabs: Array<{ id: number; url: string; title: string; active: boolean }> = []; for (const [id, page] of this.pages) { tabs.push({ id, url: page.url(), title: await page.title().catch(() => ''), active: id === this.activeTabId, }); } return tabs; } // ─── Page Access ─────────────────────────────────────────── getPage(): Page { const page = this.pages.get(this.activeTabId); if (!page) throw new Error('No active page. Use "browse goto " first.'); return page; } getCurrentUrl(): string { try { return this.getPage().url(); } catch { return 'about:blank'; } } // ─── Ref Map ────────────────────────────────────────────── setRefMap(refs: Map) { this.refMap = refs; } clearRefs() { this.refMap.clear(); } /** * Resolve a selector that may be a @ref (e.g., "@e3") or a CSS selector. * Returns { locator } for refs or { selector } for CSS selectors. */ resolveRef(selector: string): { locator: Locator } | { selector: string } { if (selector.startsWith('@e')) { const ref = selector.slice(1); // "e3" const locator = this.refMap.get(ref); if (!locator) { throw new Error( `Ref ${selector} not found. Page may have changed — run 'snapshot' to get fresh refs.` ); } return { locator }; } return { selector }; } getRefCount(): number { return this.refMap.size; } // ─── Viewport ────────────────────────────────────────────── async setViewport(width: number, height: number) { await this.getPage().setViewportSize({ width, height }); } // ─── Extra Headers ───────────────────────────────────────── async setExtraHeader(name: string, value: string) { this.extraHeaders[name] = value; if (this.context) { await this.context.setExtraHTTPHeaders(this.extraHeaders); } } // ─── User Agent ──────────────────────────────────────────── // Note: user agent changes require a new context in Playwright // For simplicity, we just store it and apply on next "restart" setUserAgent(ua: string) { this.customUserAgent = ua; } // ─── Console/Network/Ref Wiring ──────────────────────────── private wirePageEvents(page: Page) { // Clear ref map on navigation — refs point to stale elements after page change page.on('framenavigated', (frame) => { if (frame === page.mainFrame()) { this.clearRefs(); } }); page.on('console', (msg) => { addConsoleEntry({ timestamp: Date.now(), level: msg.type(), text: msg.text(), }); }); page.on('request', (req) => { addNetworkEntry({ timestamp: Date.now(), method: req.method(), url: req.url(), }); }); page.on('response', (res) => { // Find matching request entry and update it const url = res.url(); const status = res.status(); for (let i = networkBuffer.length - 1; i >= 0; i--) { if (networkBuffer[i].url === url && !networkBuffer[i].status) { networkBuffer[i].status = status; networkBuffer[i].duration = Date.now() - networkBuffer[i].timestamp; break; } } }); // Capture response sizes via response finished page.on('requestfinished', async (req) => { try { const res = await req.response(); if (res) { const url = req.url(); const body = await res.body().catch(() => null); const size = body ? body.length : 0; for (let i = networkBuffer.length - 1; i >= 0; i--) { if (networkBuffer[i].url === url && !networkBuffer[i].size) { networkBuffer[i].size = size; break; } } } } catch {} }); } }