import type { Browser, BrowserContext, ConsoleMessage, Page, } from "playwright-core"; import { chromium } from "playwright-core"; import { formatErrorMessage } from "../infra/errors.js"; import { getChromeWebSocketUrl } from "./chrome.js"; export type BrowserConsoleMessage = { type: string; text: string; timestamp: string; location?: { url?: string; lineNumber?: number; columnNumber?: number }; }; type SnapshotForAIResult = { full: string; incremental?: string }; type SnapshotForAIOptions = { timeout?: number; track?: string }; export type WithSnapshotForAI = { _snapshotForAI?: ( options?: SnapshotForAIOptions, ) => Promise; }; type TargetInfoResponse = { targetInfo?: { targetId?: string; }; }; type ConnectedBrowser = { browser: Browser; cdpUrl: string; }; type PageState = { console: BrowserConsoleMessage[]; armIdUpload: number; armIdDialog: number; }; const pageStates = new WeakMap(); const observedContexts = new WeakSet(); const observedPages = new WeakSet(); const MAX_CONSOLE_MESSAGES = 500; let cached: ConnectedBrowser | null = null; let connecting: Promise | null = null; function normalizeCdpUrl(raw: string) { return raw.replace(/\/$/, ""); } export function ensurePageState(page: Page): PageState { const existing = pageStates.get(page); if (existing) return existing; const state: PageState = { console: [], armIdUpload: 0, armIdDialog: 0, }; pageStates.set(page, state); if (!observedPages.has(page)) { observedPages.add(page); page.on("console", (msg: ConsoleMessage) => { const entry: BrowserConsoleMessage = { type: msg.type(), text: msg.text(), timestamp: new Date().toISOString(), location: msg.location(), }; state.console.push(entry); if (state.console.length > MAX_CONSOLE_MESSAGES) state.console.shift(); }); page.on("close", () => { pageStates.delete(page); observedPages.delete(page); }); } return state; } function observeContext(context: BrowserContext) { if (observedContexts.has(context)) return; observedContexts.add(context); for (const page of context.pages()) ensurePageState(page); context.on("page", (page) => ensurePageState(page)); } function observeBrowser(browser: Browser) { for (const context of browser.contexts()) observeContext(context); } async function connectBrowser(cdpUrl: string): Promise { const normalized = normalizeCdpUrl(cdpUrl); if (cached?.cdpUrl === normalized) return cached; if (connecting) return await connecting; const connectWithRetry = async (): Promise => { let lastErr: unknown; for (let attempt = 0; attempt < 3; attempt += 1) { try { const timeout = 5000 + attempt * 2000; const wsUrl = await getChromeWebSocketUrl(normalized, timeout).catch( () => null, ); const endpoint = wsUrl ?? normalized; const browser = await chromium.connectOverCDP(endpoint, { timeout }); const connected: ConnectedBrowser = { browser, cdpUrl: normalized }; cached = connected; observeBrowser(browser); browser.on("disconnected", () => { if (cached?.browser === browser) cached = null; }); return connected; } catch (err) { lastErr = err; const delay = 250 + attempt * 250; await new Promise((r) => setTimeout(r, delay)); } } if (lastErr instanceof Error) { throw lastErr; } const message = lastErr ? formatErrorMessage(lastErr) : "CDP connect failed"; throw new Error(message); }; connecting = connectWithRetry().finally(() => { connecting = null; }); return await connecting; } async function getAllPages(browser: Browser): Promise { const contexts = browser.contexts(); const pages = contexts.flatMap((c) => c.pages()); return pages; } async function pageTargetId(page: Page): Promise { const session = await page.context().newCDPSession(page); try { const info = (await session.send( "Target.getTargetInfo", )) as TargetInfoResponse; const targetId = String(info?.targetInfo?.targetId ?? "").trim(); return targetId || null; } finally { await session.detach().catch(() => {}); } } async function findPageByTargetId( browser: Browser, targetId: string, ): Promise { const pages = await getAllPages(browser); for (const page of pages) { const tid = await pageTargetId(page).catch(() => null); if (tid && tid === targetId) return page; } return null; } export async function getPageForTargetId(opts: { cdpUrl: string; targetId?: string; }): Promise { const { browser } = await connectBrowser(opts.cdpUrl); const pages = await getAllPages(browser); if (!pages.length) throw new Error("No pages available in the connected browser."); const first = pages[0]; if (!opts.targetId) return first; const found = await findPageByTargetId(browser, opts.targetId); if (!found) throw new Error("tab not found"); return found; } export function refLocator(page: Page, ref: string) { return page.locator(`aria-ref=${ref}`); } export async function closePlaywrightBrowserConnection(): Promise { const cur = cached; cached = null; if (!cur) return; await cur.browser.close().catch(() => {}); }