| import type { |
| Browser, |
| BrowserContext, |
| ConsoleMessage, |
| Page, |
| Request, |
| Response, |
| } from "playwright-core"; |
| import { chromium } from "playwright-core"; |
| import { formatErrorMessage } from "../infra/errors.js"; |
| import { getHeadersWithAuth } from "./cdp.helpers.js"; |
| import { getChromeWebSocketUrl } from "./chrome.js"; |
|
|
| export type BrowserConsoleMessage = { |
| type: string; |
| text: string; |
| timestamp: string; |
| location?: { url?: string; lineNumber?: number; columnNumber?: number }; |
| }; |
|
|
| export type BrowserPageError = { |
| message: string; |
| name?: string; |
| stack?: string; |
| timestamp: string; |
| }; |
|
|
| export type BrowserNetworkRequest = { |
| id: string; |
| timestamp: string; |
| method: string; |
| url: string; |
| resourceType?: string; |
| status?: number; |
| ok?: boolean; |
| failureText?: string; |
| }; |
|
|
| type SnapshotForAIResult = { full: string; incremental?: string }; |
| type SnapshotForAIOptions = { timeout?: number; track?: string }; |
|
|
| export type WithSnapshotForAI = { |
| _snapshotForAI?: (options?: SnapshotForAIOptions) => Promise<SnapshotForAIResult>; |
| }; |
|
|
| type TargetInfoResponse = { |
| targetInfo?: { |
| targetId?: string; |
| }; |
| }; |
|
|
| type ConnectedBrowser = { |
| browser: Browser; |
| cdpUrl: string; |
| }; |
|
|
| type PageState = { |
| console: BrowserConsoleMessage[]; |
| errors: BrowserPageError[]; |
| requests: BrowserNetworkRequest[]; |
| requestIds: WeakMap<Request, string>; |
| nextRequestId: number; |
| armIdUpload: number; |
| armIdDialog: number; |
| armIdDownload: number; |
| |
| |
| |
| |
| |
| roleRefs?: Record<string, { role: string; name?: string; nth?: number }>; |
| roleRefsMode?: "role" | "aria"; |
| roleRefsFrameSelector?: string; |
| }; |
|
|
| type RoleRefs = NonNullable<PageState["roleRefs"]>; |
| type RoleRefsCacheEntry = { |
| refs: RoleRefs; |
| frameSelector?: string; |
| mode?: NonNullable<PageState["roleRefsMode"]>; |
| }; |
|
|
| type ContextState = { |
| traceActive: boolean; |
| }; |
|
|
| const pageStates = new WeakMap<Page, PageState>(); |
| const contextStates = new WeakMap<BrowserContext, ContextState>(); |
| const observedContexts = new WeakSet<BrowserContext>(); |
| const observedPages = new WeakSet<Page>(); |
|
|
| |
| |
| const roleRefsByTarget = new Map<string, RoleRefsCacheEntry>(); |
| const MAX_ROLE_REFS_CACHE = 50; |
|
|
| const MAX_CONSOLE_MESSAGES = 500; |
| const MAX_PAGE_ERRORS = 200; |
| const MAX_NETWORK_REQUESTS = 500; |
|
|
| let cached: ConnectedBrowser | null = null; |
| let connecting: Promise<ConnectedBrowser> | null = null; |
|
|
| function normalizeCdpUrl(raw: string) { |
| return raw.replace(/\/$/, ""); |
| } |
|
|
| function roleRefsKey(cdpUrl: string, targetId: string) { |
| return `${normalizeCdpUrl(cdpUrl)}::${targetId}`; |
| } |
|
|
| export function rememberRoleRefsForTarget(opts: { |
| cdpUrl: string; |
| targetId: string; |
| refs: RoleRefs; |
| frameSelector?: string; |
| mode?: NonNullable<PageState["roleRefsMode"]>; |
| }): void { |
| const targetId = opts.targetId.trim(); |
| if (!targetId) { |
| return; |
| } |
| roleRefsByTarget.set(roleRefsKey(opts.cdpUrl, targetId), { |
| refs: opts.refs, |
| ...(opts.frameSelector ? { frameSelector: opts.frameSelector } : {}), |
| ...(opts.mode ? { mode: opts.mode } : {}), |
| }); |
| while (roleRefsByTarget.size > MAX_ROLE_REFS_CACHE) { |
| const first = roleRefsByTarget.keys().next(); |
| if (first.done) { |
| break; |
| } |
| roleRefsByTarget.delete(first.value); |
| } |
| } |
|
|
| export function storeRoleRefsForTarget(opts: { |
| page: Page; |
| cdpUrl: string; |
| targetId?: string; |
| refs: RoleRefs; |
| frameSelector?: string; |
| mode: NonNullable<PageState["roleRefsMode"]>; |
| }): void { |
| const state = ensurePageState(opts.page); |
| state.roleRefs = opts.refs; |
| state.roleRefsFrameSelector = opts.frameSelector; |
| state.roleRefsMode = opts.mode; |
| if (!opts.targetId?.trim()) { |
| return; |
| } |
| rememberRoleRefsForTarget({ |
| cdpUrl: opts.cdpUrl, |
| targetId: opts.targetId, |
| refs: opts.refs, |
| frameSelector: opts.frameSelector, |
| mode: opts.mode, |
| }); |
| } |
|
|
| export function restoreRoleRefsForTarget(opts: { |
| cdpUrl: string; |
| targetId?: string; |
| page: Page; |
| }): void { |
| const targetId = opts.targetId?.trim() || ""; |
| if (!targetId) { |
| return; |
| } |
| const cached = roleRefsByTarget.get(roleRefsKey(opts.cdpUrl, targetId)); |
| if (!cached) { |
| return; |
| } |
| const state = ensurePageState(opts.page); |
| if (state.roleRefs) { |
| return; |
| } |
| state.roleRefs = cached.refs; |
| state.roleRefsFrameSelector = cached.frameSelector; |
| state.roleRefsMode = cached.mode; |
| } |
|
|
| export function ensurePageState(page: Page): PageState { |
| const existing = pageStates.get(page); |
| if (existing) { |
| return existing; |
| } |
|
|
| const state: PageState = { |
| console: [], |
| errors: [], |
| requests: [], |
| requestIds: new WeakMap(), |
| nextRequestId: 0, |
| armIdUpload: 0, |
| armIdDialog: 0, |
| armIdDownload: 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("pageerror", (err: Error) => { |
| state.errors.push({ |
| message: err?.message ? String(err.message) : String(err), |
| name: err?.name ? String(err.name) : undefined, |
| stack: err?.stack ? String(err.stack) : undefined, |
| timestamp: new Date().toISOString(), |
| }); |
| if (state.errors.length > MAX_PAGE_ERRORS) { |
| state.errors.shift(); |
| } |
| }); |
| page.on("request", (req: Request) => { |
| state.nextRequestId += 1; |
| const id = `r${state.nextRequestId}`; |
| state.requestIds.set(req, id); |
| state.requests.push({ |
| id, |
| timestamp: new Date().toISOString(), |
| method: req.method(), |
| url: req.url(), |
| resourceType: req.resourceType(), |
| }); |
| if (state.requests.length > MAX_NETWORK_REQUESTS) { |
| state.requests.shift(); |
| } |
| }); |
| page.on("response", (resp: Response) => { |
| const req = resp.request(); |
| const id = state.requestIds.get(req); |
| if (!id) { |
| return; |
| } |
| let rec: BrowserNetworkRequest | undefined; |
| for (let i = state.requests.length - 1; i >= 0; i -= 1) { |
| const candidate = state.requests[i]; |
| if (candidate && candidate.id === id) { |
| rec = candidate; |
| break; |
| } |
| } |
| if (!rec) { |
| return; |
| } |
| rec.status = resp.status(); |
| rec.ok = resp.ok(); |
| }); |
| page.on("requestfailed", (req: Request) => { |
| const id = state.requestIds.get(req); |
| if (!id) { |
| return; |
| } |
| let rec: BrowserNetworkRequest | undefined; |
| for (let i = state.requests.length - 1; i >= 0; i -= 1) { |
| const candidate = state.requests[i]; |
| if (candidate && candidate.id === id) { |
| rec = candidate; |
| break; |
| } |
| } |
| if (!rec) { |
| return; |
| } |
| rec.failureText = req.failure()?.errorText; |
| rec.ok = false; |
| }); |
| page.on("close", () => { |
| pageStates.delete(page); |
| observedPages.delete(page); |
| }); |
| } |
|
|
| return state; |
| } |
|
|
| function observeContext(context: BrowserContext) { |
| if (observedContexts.has(context)) { |
| return; |
| } |
| observedContexts.add(context); |
| ensureContextState(context); |
|
|
| for (const page of context.pages()) { |
| ensurePageState(page); |
| } |
| context.on("page", (page) => ensurePageState(page)); |
| } |
|
|
| export function ensureContextState(context: BrowserContext): ContextState { |
| const existing = contextStates.get(context); |
| if (existing) { |
| return existing; |
| } |
| const state: ContextState = { traceActive: false }; |
| contextStates.set(context, state); |
| return state; |
| } |
|
|
| function observeBrowser(browser: Browser) { |
| for (const context of browser.contexts()) { |
| observeContext(context); |
| } |
| } |
|
|
| async function connectBrowser(cdpUrl: string): Promise<ConnectedBrowser> { |
| const normalized = normalizeCdpUrl(cdpUrl); |
| if (cached?.cdpUrl === normalized) { |
| return cached; |
| } |
| if (connecting) { |
| return await connecting; |
| } |
|
|
| const connectWithRetry = async (): Promise<ConnectedBrowser> => { |
| 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 headers = getHeadersWithAuth(endpoint); |
| const browser = await chromium.connectOverCDP(endpoint, { timeout, headers }); |
| 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<Page[]> { |
| const contexts = browser.contexts(); |
| const pages = contexts.flatMap((c) => c.pages()); |
| return pages; |
| } |
|
|
| async function pageTargetId(page: Page): Promise<string | null> { |
| 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, |
| cdpUrl?: string, |
| ): Promise<Page | null> { |
| const pages = await getAllPages(browser); |
| |
| for (const page of pages) { |
| const tid = await pageTargetId(page).catch(() => null); |
| if (tid && tid === targetId) { |
| return page; |
| } |
| } |
| |
| |
| if (cdpUrl) { |
| try { |
| const baseUrl = cdpUrl |
| .replace(/\/+$/, "") |
| .replace(/^ws:/, "http:") |
| .replace(/\/cdp$/, ""); |
| const response = await fetch(`${baseUrl}/json/list`); |
| if (response.ok) { |
| const targets = (await response.json()) as Array<{ |
| id: string; |
| url: string; |
| title?: string; |
| }>; |
| const target = targets.find((t) => t.id === targetId); |
| if (target) { |
| |
| const urlMatch = pages.filter((p) => p.url() === target.url); |
| if (urlMatch.length === 1) { |
| return urlMatch[0]; |
| } |
| |
| |
| if (urlMatch.length > 1) { |
| const sameUrlTargets = targets.filter((t) => t.url === target.url); |
| if (sameUrlTargets.length === urlMatch.length) { |
| const idx = sameUrlTargets.findIndex((t) => t.id === targetId); |
| if (idx >= 0 && idx < urlMatch.length) { |
| return urlMatch[idx]; |
| } |
| } |
| } |
| } |
| } |
| } catch { |
| |
| } |
| } |
| return null; |
| } |
|
|
| export async function getPageForTargetId(opts: { |
| cdpUrl: string; |
| targetId?: string; |
| }): Promise<Page> { |
| 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, opts.cdpUrl); |
| if (!found) { |
| |
| |
| |
| if (pages.length === 1) { |
| return first; |
| } |
| throw new Error("tab not found"); |
| } |
| return found; |
| } |
|
|
| export function refLocator(page: Page, ref: string) { |
| const normalized = ref.startsWith("@") |
| ? ref.slice(1) |
| : ref.startsWith("ref=") |
| ? ref.slice(4) |
| : ref; |
|
|
| if (/^e\d+$/.test(normalized)) { |
| const state = pageStates.get(page); |
| if (state?.roleRefsMode === "aria") { |
| const scope = state.roleRefsFrameSelector |
| ? page.frameLocator(state.roleRefsFrameSelector) |
| : page; |
| return scope.locator(`aria-ref=${normalized}`); |
| } |
| const info = state?.roleRefs?.[normalized]; |
| if (!info) { |
| throw new Error( |
| `Unknown ref "${normalized}". Run a new snapshot and use a ref from that snapshot.`, |
| ); |
| } |
| const scope = state?.roleRefsFrameSelector |
| ? page.frameLocator(state.roleRefsFrameSelector) |
| : page; |
| const locAny = scope as unknown as { |
| getByRole: ( |
| role: never, |
| opts?: { name?: string; exact?: boolean }, |
| ) => ReturnType<Page["getByRole"]>; |
| }; |
| const locator = info.name |
| ? locAny.getByRole(info.role as never, { name: info.name, exact: true }) |
| : locAny.getByRole(info.role as never); |
| return info.nth !== undefined ? locator.nth(info.nth) : locator; |
| } |
|
|
| return page.locator(`aria-ref=${normalized}`); |
| } |
|
|
| export async function closePlaywrightBrowserConnection(): Promise<void> { |
| const cur = cached; |
| cached = null; |
| if (!cur) { |
| return; |
| } |
| await cur.browser.close().catch(() => {}); |
| } |
|
|
| |
| |
| |
| |
| export async function listPagesViaPlaywright(opts: { cdpUrl: string }): Promise< |
| Array<{ |
| targetId: string; |
| title: string; |
| url: string; |
| type: string; |
| }> |
| > { |
| const { browser } = await connectBrowser(opts.cdpUrl); |
| const pages = await getAllPages(browser); |
| const results: Array<{ |
| targetId: string; |
| title: string; |
| url: string; |
| type: string; |
| }> = []; |
|
|
| for (const page of pages) { |
| const tid = await pageTargetId(page).catch(() => null); |
| if (tid) { |
| results.push({ |
| targetId: tid, |
| title: await page.title().catch(() => ""), |
| url: page.url(), |
| type: "page", |
| }); |
| } |
| } |
| return results; |
| } |
|
|
| |
| |
| |
| |
| |
| export async function createPageViaPlaywright(opts: { cdpUrl: string; url: string }): Promise<{ |
| targetId: string; |
| title: string; |
| url: string; |
| type: string; |
| }> { |
| const { browser } = await connectBrowser(opts.cdpUrl); |
| const context = browser.contexts()[0] ?? (await browser.newContext()); |
| ensureContextState(context); |
|
|
| const page = await context.newPage(); |
| ensurePageState(page); |
|
|
| |
| const targetUrl = opts.url.trim() || "about:blank"; |
| if (targetUrl !== "about:blank") { |
| await page.goto(targetUrl, { timeout: 30_000 }).catch(() => { |
| |
| }); |
| } |
|
|
| |
| const tid = await pageTargetId(page).catch(() => null); |
| if (!tid) { |
| throw new Error("Failed to get targetId for new page"); |
| } |
|
|
| return { |
| targetId: tid, |
| title: await page.title().catch(() => ""), |
| url: page.url(), |
| type: "page", |
| }; |
| } |
|
|
| |
| |
| |
| |
| export async function closePageByTargetIdViaPlaywright(opts: { |
| cdpUrl: string; |
| targetId: string; |
| }): Promise<void> { |
| const { browser } = await connectBrowser(opts.cdpUrl); |
| const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl); |
| if (!page) { |
| throw new Error("tab not found"); |
| } |
| await page.close(); |
| } |
|
|
| |
| |
| |
| |
| export async function focusPageByTargetIdViaPlaywright(opts: { |
| cdpUrl: string; |
| targetId: string; |
| }): Promise<void> { |
| const { browser } = await connectBrowser(opts.cdpUrl); |
| const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl); |
| if (!page) { |
| throw new Error("tab not found"); |
| } |
| try { |
| await page.bringToFront(); |
| } catch (err) { |
| const session = await page.context().newCDPSession(page); |
| try { |
| await session.send("Page.bringToFront"); |
| return; |
| } catch { |
| throw err; |
| } finally { |
| await session.detach().catch(() => {}); |
| } |
| } |
| } |
|
|