511 lines
15 KiB
TypeScript
511 lines
15 KiB
TypeScript
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;
|
|
/**
|
|
* Role-based refs from the last role snapshot (e.g. e1/e2).
|
|
* Mode "role" refs are generated from ariaSnapshot and resolved via getByRole.
|
|
* Mode "aria" refs are Playwright aria-ref ids and resolved via `aria-ref=...`.
|
|
*/
|
|
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>();
|
|
|
|
// Best-effort cache to make role refs stable even if Playwright returns a different Page object
|
|
// for the same CDP target across requests.
|
|
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 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): 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;
|
|
}
|
|
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);
|
|
if (!found) {
|
|
// Extension relays can block CDP attachment APIs (e.g. Target.attachToBrowserTarget),
|
|
// which prevents us from resolving a page's targetId via newCDPSession(). If Playwright
|
|
// only exposes a single Page, use it as a best-effort fallback.
|
|
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(() => {});
|
|
}
|
|
|
|
/**
|
|
* List all pages/tabs from the persistent Playwright connection.
|
|
* Used for remote profiles where HTTP-based /json/list is ephemeral.
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Create a new page/tab using the persistent Playwright connection.
|
|
* Used for remote profiles where HTTP-based /json/new is ephemeral.
|
|
* Returns the new page's targetId and metadata.
|
|
*/
|
|
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);
|
|
|
|
// Navigate to the URL
|
|
const targetUrl = opts.url.trim() || "about:blank";
|
|
if (targetUrl !== "about:blank") {
|
|
await page.goto(targetUrl, { timeout: 30_000 }).catch(() => {
|
|
// Navigation might fail for some URLs, but page is still created
|
|
});
|
|
}
|
|
|
|
// Get the targetId for this page
|
|
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",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Close a page/tab by targetId using the persistent Playwright connection.
|
|
* Used for remote profiles where HTTP-based /json/close is ephemeral.
|
|
*/
|
|
export async function closePageByTargetIdViaPlaywright(opts: {
|
|
cdpUrl: string;
|
|
targetId: string;
|
|
}): Promise<void> {
|
|
const { browser } = await connectBrowser(opts.cdpUrl);
|
|
const page = await findPageByTargetId(browser, opts.targetId);
|
|
if (!page) {
|
|
throw new Error("tab not found");
|
|
}
|
|
await page.close();
|
|
}
|
|
|
|
/**
|
|
* Focus a page/tab by targetId using the persistent Playwright connection.
|
|
* Used for remote profiles where HTTP-based /json/activate can be ephemeral.
|
|
*/
|
|
export async function focusPageByTargetIdViaPlaywright(opts: {
|
|
cdpUrl: string;
|
|
targetId: string;
|
|
}): Promise<void> {
|
|
const { browser } = await connectBrowser(opts.cdpUrl);
|
|
const page = await findPageByTargetId(browser, opts.targetId);
|
|
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(() => {});
|
|
}
|
|
}
|
|
}
|