import * as os from "node:os"; import { getEnhancedSnapshot, parseRef } from "./snapshot.js"; function expandTilde(p) { if (!p.startsWith("~/")) return p; return `${os.homedir()}/${p.slice(2)}`; } export class BrowserManager { browser = null; context = null; pages = []; activePageIndex = 0; activeFrame = null; isPersistentContext = false; launchConfig = null; dialogHandler = null; dialogHandlerPage = null; consoleMessages = []; pageErrors = []; refMap = {}; lastSnapshot = ""; isLaunched() { return this.browser !== null || this.isPersistentContext; } hasPages() { return this.pages.length > 0; } async ensurePage() { if (!this.context) throw new Error("Browser not launched. Call open first."); if (this.pages.length > 0) return; const page = this.context.pages()[0] || (await this.context.newPage()); this.trackPage(page); this.activePageIndex = this.pages.indexOf(page); if (this.activePageIndex < 0) this.activePageIndex = 0; this.activeFrame = null; await page.bringToFront().catch(() => { }); } getPage() { const page = this.pages[this.activePageIndex] || this.pages[0]; if (!page) { throw new Error("Browser not launched. Call open first."); } return page; } getFrame() { if (this.activeFrame) return this.activeFrame; return this.getPage().mainFrame(); } getContext() { if (!this.context) { throw new Error("Browser not launched. Call open first."); } return this.context; } getActiveIndex() { return this.activePageIndex; } getConsoleMessages() { return this.consoleMessages; } clearConsoleMessages() { this.consoleMessages = []; } getPageErrors() { return this.pageErrors; } clearPageErrors() { this.pageErrors = []; } getLastSnapshot() { return this.lastSnapshot; } getRefMap() { return this.refMap; } getLaunchConfig() { return this.launchConfig; } trackPage(page) { if (this.pages.includes(page)) return; this.pages.push(page); page.on("console", (msg) => { this.consoleMessages.push({ type: msg.type(), text: msg.text(), timestamp: Date.now() }); }); page.on("pageerror", (error) => { this.pageErrors.push({ message: error.message, timestamp: Date.now() }); }); page.on("close", () => { const idx = this.pages.indexOf(page); if (idx < 0) return; if (idx === this.activePageIndex) { this.activeFrame = null; } this.pages.splice(idx, 1); if (this.pages.length === 0) { this.activePageIndex = 0; return; } if (this.activePageIndex >= this.pages.length) { this.activePageIndex = this.pages.length - 1; } else if (this.activePageIndex > idx) { this.activePageIndex--; } }); } setupContextTracking(context) { context.on("page", (page) => { this.trackPage(page); const idx = this.pages.indexOf(page); if (idx >= 0 && idx !== this.activePageIndex) { this.activePageIndex = idx; this.activeFrame = null; } }); } async launch(config) { if (this.isLaunched()) return; this.launchConfig = config; let Camoufox; try { ({ Camoufox } = await import("camoufox-js")); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message.includes("better-sqlite3") && message.includes("Could not locate the bindings file")) { throw new Error([ "better-sqlite3 native bindings are missing (required by camoufox-js).", "If you installed dependencies with pnpm, allow build scripts and rebuild:", " pnpm -C camoufox-browser install", " pnpm -C camoufox-browser rebuild better-sqlite3", "If you're using npm, reinstall should run the build scripts automatically." ].join("\n")); } throw err; } const headless = config.headless; const executable_path = config.executablePath; const args = config.args; const proxy = config.proxy; if (config.profile) { const user_data_dir = expandTilde(config.profile); const ctx = (await Camoufox({ headless, user_data_dir, ...(executable_path ? { executable_path } : {}), ...(args ? { args } : {}), ...(proxy ? { proxy } : {}), ...(config.userAgent ? { userAgent: config.userAgent } : {}), ...(config.headers ? { extraHTTPHeaders: config.headers } : {}), ...(config.ignoreHTTPSErrors ? { ignoreHTTPSErrors: true } : {}) })); this.context = ctx; this.isPersistentContext = true; this.setupContextTracking(ctx); const initial = ctx.pages(); if (initial.length > 0) { for (const p of initial) this.trackPage(p); this.activePageIndex = 0; } else { const p = await ctx.newPage(); this.trackPage(p); this.activePageIndex = this.pages.indexOf(p); } await this.getPage().bringToFront().catch(() => { }); return; } const b = (await Camoufox({ headless, ...(executable_path ? { executable_path } : {}), ...(args ? { args } : {}), ...(proxy ? { proxy } : {}) })); this.browser = b; this.context = await b.newContext({ ...(config.userAgent ? { userAgent: config.userAgent } : {}), ...(config.headers ? { extraHTTPHeaders: config.headers } : {}), ...(config.storageState ? { storageState: config.storageState } : {}), ...(config.ignoreHTTPSErrors ? { ignoreHTTPSErrors: true } : {}) }); this.setupContextTracking(this.context); const initial = this.context.pages(); if (initial.length > 0) { for (const p of initial) this.trackPage(p); this.activePageIndex = 0; } else { const p = await this.context.newPage(); this.trackPage(p); this.activePageIndex = this.pages.indexOf(p); } await this.getPage().bringToFront().catch(() => { }); } async navigate(url, options) { await this.ensurePage(); const page = this.getPage(); if (options?.headers && Object.keys(options.headers).length > 0) { await page.setExtraHTTPHeaders(options.headers); } await page.goto(url, { waitUntil: options?.waitUntil ?? "load" }); return { url: page.url(), title: await page.title() }; } async newTab() { const ctx = this.getContext(); const page = await ctx.newPage(); this.trackPage(page); this.activePageIndex = this.pages.indexOf(page); if (this.activePageIndex < 0) this.activePageIndex = Math.max(0, this.pages.length - 1); this.activeFrame = null; await page.bringToFront().catch(() => { }); return page; } async newTabManaged() { await this.newTab(); return { index: this.activePageIndex, total: this.pages.length }; } async switchTo(index) { if (index < 0 || index >= this.pages.length) { throw new Error(`Invalid tab index: ${index}. Available: 0-${Math.max(0, this.pages.length - 1)}`); } this.activePageIndex = index; this.activeFrame = null; const page = this.getPage(); await page.bringToFront().catch(() => { }); return { index: this.activePageIndex, url: page.url(), title: "" }; } async closeTab(index) { const targetIndex = index ?? this.activePageIndex; if (targetIndex < 0 || targetIndex >= this.pages.length) { throw new Error(`Invalid tab index: ${targetIndex}`); } if (this.pages.length === 1) { throw new Error('Cannot close the last tab. Use "close" to close the browser.'); } const page = this.pages[targetIndex]; await page.close(); const idx = this.pages.indexOf(page); if (idx >= 0) { this.pages.splice(idx, 1); } if (this.activePageIndex >= this.pages.length) { this.activePageIndex = this.pages.length - 1; } else if (this.activePageIndex > targetIndex) { this.activePageIndex--; } await this.getPage().bringToFront().catch(() => { }); return { closed: targetIndex, remaining: this.pages.length }; } async listTabs() { return await Promise.all(this.pages.map(async (page, index) => ({ index, url: page.url(), title: await page.title().catch(() => ""), active: index === this.activePageIndex }))); } async getSnapshot(options) { const frame = this.getFrame(); const snapshot = await getEnhancedSnapshot(frame, options); this.refMap = snapshot.refs; this.lastSnapshot = snapshot.tree; return snapshot; } getLocatorFromRef(refArg) { const ref = parseRef(refArg); if (!ref) return null; const refData = this.refMap[ref]; if (!refData) return null; const frame = this.getFrame(); if (refData.role === "clickable" || refData.role === "focusable") { return frame.locator(refData.selector); } let locator = frame.getByRole(refData.role, { name: refData.name, exact: true }); if (refData.nth !== undefined) { locator = locator.nth(refData.nth); } return locator; } getLocator(selectorOrRef) { const refLocator = this.getLocatorFromRef(selectorOrRef); if (refLocator) return refLocator; return this.getFrame().locator(selectorOrRef); } switchToMainFrame() { this.activeFrame = null; } async switchToFrame(options) { const page = this.getPage(); if (options.selector) { const frameElement = await page.$(options.selector); if (!frameElement) { throw new Error(`Frame not found: ${options.selector}`); } const frame = await frameElement.contentFrame(); if (!frame) { throw new Error(`Element is not a frame: ${options.selector}`); } this.activeFrame = frame; return; } if (options.name) { const frame = page.frame({ name: options.name }); if (!frame) throw new Error(`Frame not found with name: ${options.name}`); this.activeFrame = frame; return; } if (options.url) { const frame = page.frame({ url: options.url }); if (!frame) throw new Error(`Frame not found with URL: ${options.url}`); this.activeFrame = frame; return; } throw new Error("Missing frame selector"); } setDialogHandler(response, promptText) { const page = this.getPage(); if (this.dialogHandler && this.dialogHandlerPage) { this.dialogHandlerPage.removeListener("dialog", this.dialogHandler); } this.dialogHandler = async (dialog) => { if (response === "accept") { await dialog.accept(promptText); } else { await dialog.dismiss(); } }; this.dialogHandlerPage = page; page.on("dialog", this.dialogHandler); } async close() { const browser = this.browser; const context = this.context; this.browser = null; this.context = null; this.pages = []; this.activePageIndex = 0; this.activeFrame = null; this.isPersistentContext = false; this.launchConfig = null; this.dialogHandler = null; this.dialogHandlerPage = null; this.refMap = {}; this.lastSnapshot = ""; if (browser) { try { await browser.close(); } catch { } return; } if (context) { try { await context.close(); } catch { } } } }