/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { Disposable, DisposableMap, IDisposable } from '../../../base/common/lifecycle.js'; import { DeferredPromise, disposableTimeout, raceTimeout, timeout } from '../../../base/common/async.js'; import { ILogService } from '../../log/common/log.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { IAgentNetworkFilterService } from '../../networkFilter/common/networkFilterService.js'; import { IInvokeFunctionResult, IPlaywrightService } from '../common/playwrightService.js'; import { IBrowserViewGroupRemoteService } from '../node/browserViewGroupRemoteService.js'; import { IBrowserViewGroup } from '../common/browserViewGroup.js'; import { getAgentBrowserViewCreationDefaults } from '../common/browserView.js'; import { PlaywrightTab, DialogInterruptedError } from './playwrightTab.js'; import { CDPRequest, CDPResponse, CDPTargetInfo } from '../common/cdp/types.js'; import { generateUuid } from '../../../base/common/uuid.js'; // eslint-disable-next-line local/code-import-patterns import type { Browser, BrowserContext, ConnectOverCDPTransport, Page } from 'playwright-core'; /** * Tracks whether a caller-initiated Playwright action is currently in flight. */ export interface IPlaywrightActionScope { activeCalls: number; } const DEFERRED_RESULT_CLEANUP_MS = 5 * 60_000; // 5 minutes const SESSION_INACTIVITY_MS = 30 * 60_000; // 30 minutes const OPEN_PAGE_NAVIGATION_TIMEOUT_MS = 30_000; /** * Narrow a raw Playwright transport payload to a {@link CDPRequest}. * * Playwright types the `send` payload as `object` but passes structured CDP * messages (not JSON strings) for a caller-supplied transport, so this guard * is expected to always hold. It exists to fail loudly (the caller throws) * should a future Playwright version change the wire format, rather than * silently forwarding malformed messages. */ function isCDPRequest(message: object): message is CDPRequest { const candidate = message as Partial; return typeof candidate.id === 'number' && typeof candidate.method === 'string' && (candidate.sessionId === undefined || typeof candidate.sessionId === 'string'); } /** * Shared-process implementation of {@link IPlaywrightService}. * * Manages {@link PlaywrightSession} instances keyed by session ID. * Each session has its own Playwright browser connection and browser view * group, created eagerly by the service when the session is first requested. * * Each session receives an independent CDP group whose membership is driven by * the main-process browser-view audience state. */ export class PlaywrightService extends Disposable implements IPlaywrightService { declare readonly _serviceBrand: undefined; private readonly _sessions = this._register(new DisposableMap()); /** In-flight session initializations keyed by session ID. */ private readonly _pendingInits = new Map>(); /** Inactivity timers keyed by session ID. */ private readonly _inactivityTimers = this._register(new DisposableMap()); constructor( private readonly windowId: number, private readonly browserViewGroupRemoteService: IBrowserViewGroupRemoteService, private readonly logService: ILogService, private readonly agentNetworkFilterService: IAgentNetworkFilterService, private readonly telemetryService: ITelemetryService, ) { super(); } /** * Get or create a fully-initialized {@link PlaywrightSession} for the * given session ID. Creates the CDP group and Playwright browser * connection if the session does not already exist. */ private async _getOrCreateSession(sessionId: string): Promise { const existing = this._sessions.get(sessionId); if (existing) { this._touchSession(sessionId); return existing; } // De-duplicate concurrent initialization for the same session. const pending = this._pendingInits.get(sessionId); if (pending) { return pending; } const initPromise = this._initSession(sessionId); this._pendingInits.set(sessionId, initPromise); try { return await initPromise; } finally { this._pendingInits.delete(sessionId); } } /** * Create and fully initialize a new session: browser view group, * Playwright CDP connection, and page replay. */ private async _initSession(sessionId: string): Promise { this.logService.debug(`[PlaywrightService] Initializing session ${sessionId}`); const group = await this.browserViewGroupRemoteService.createGroup( { audience: { type: 'agent', sessionId } }, { host: { windowId: this.windowId }, ...getAgentBrowserViewCreationDefaults(sessionId) } ); const actionScope: IPlaywrightActionScope = { activeCalls: 0 }; let browser: Browser; try { const playwright = await import('playwright-core'); const sub = group.onCDPMessage(msg => transport.onmessage?.(msg)); const transport: ConnectOverCDPTransport = { close() { sub.dispose(); this.onclose?.(); }, send: (rawMessage) => { if (!isCDPRequest(rawMessage)) { // Fail loudly: returning silently would leave Playwright // waiting for a response and surface later as an opaque hang. throw new Error(`[PlaywrightService] Unexpected CDP transport payload for session ${sessionId} (type: ${typeof rawMessage})`); } const message = rawMessage; // Block Playwright's automatic / default emulation traffic. We // only forward `Emulation.*` to the view while a caller-initiated // action is running (see IPlaywrightActionScope) so the workbench // stays in control of device emulation. Other traffic — e.g. the // setup Playwright issues on its own when connecting or creating // pages — is acknowledged with a synthetic success response and // never hits the view. if (actionScope.activeCalls === 0 && message.method.startsWith('Emulation.')) { setTimeout(() => { transport.onmessage?.({ id: message.id, result: {}, sessionId: message.sessionId } satisfies CDPResponse); }, 1); return; } void group.sendCDPMessage(message); } }; browser = await playwright.chromium.connectOverCDP(transport); } catch (e) { group.dispose(); throw e; } this.logService.debug(`[PlaywrightService] Connected to browser for session ${sessionId}`); // If the service was disposed while we were connecting, clean up. if (this._store.isDisposed) { browser.close().catch(() => { /* ignore */ }); group.dispose(); throw new Error('PlaywrightService was disposed during initialization'); } const session = new PlaywrightSession( sessionId, browser, group, actionScope, this.logService, this.agentNetworkFilterService, this.telemetryService, ); // On browser disconnect, dispose the session so it will be // recreated fresh on the next tool call. browser.on('disconnected', () => { this.logService.debug(`[PlaywrightService] Browser disconnected for session ${sessionId}`); this._sessions.deleteAndDispose(sessionId); this._inactivityTimers.deleteAndDispose(sessionId); }); this._sessions.set(sessionId, session); this._touchSession(sessionId); return session; } // --- Playwright operations (delegated to per-session instances) --- async waitForPageAndGetSummary(sessionId: string, pageId: string, expectedUrl: string, discoveryTimeoutMs: number): Promise { const session = await this._getOrCreateSession(sessionId); return session.waitForPageAndGetSummary(pageId, expectedUrl, discoveryTimeoutMs); } async getSummary(sessionId: string, pageId: string): Promise { const session = await this._getOrCreateSession(sessionId); return session.getSummary(pageId); } async invokeFunctionRaw(sessionId: string, pageId: string, fnDef: string, ...args: unknown[]): Promise { const session = await this._getOrCreateSession(sessionId); return session.invokeFunctionRaw(pageId, fnDef, ...args); } async invokeFunction(sessionId: string, pageId: string, fnDef: string, args: unknown[] = [], timeoutMs?: number): Promise { const session = await this._getOrCreateSession(sessionId); return session.invokeFunction(pageId, fnDef, args, timeoutMs); } async waitForDeferredResult(sessionId: string, deferredResultId: string, timeoutMs: number): Promise { const session = await this._getOrCreateSession(sessionId); return session.waitForDeferredResult(deferredResultId, timeoutMs); } async replyToFileChooser(sessionId: string, pageId: string, files: string[]): Promise<{ summary: string }> { const session = await this._getOrCreateSession(sessionId); return session.replyToFileChooser(pageId, files); } async replyToDialog(sessionId: string, pageId: string, accept: boolean, promptText?: string): Promise<{ summary: string }> { const session = await this._getOrCreateSession(sessionId); return session.replyToDialog(pageId, accept, promptText); } // --- Session lifecycle --- async disposeSession(sessionId: string): Promise { if (this._sessions.has(sessionId)) { this.logService.debug(`[PlaywrightService] Disposing session ${sessionId}`); this._sessions.deleteAndDispose(sessionId); this._inactivityTimers.deleteAndDispose(sessionId); } } // --- Private helpers --- /** * Reset the inactivity timer for a session. After * {@link SESSION_INACTIVITY_MS} of no activity the session is * automatically disposed. */ private _touchSession(sessionId: string): void { this._inactivityTimers.deleteAndDispose(sessionId); const timer = disposableTimeout( () => { this.logService.debug(`[PlaywrightService] Session ${sessionId} inactive for ${SESSION_INACTIVITY_MS / 60_000}m, disposing`); this._sessions.deleteAndDispose(sessionId); this._inactivityTimers.deleteAndDispose(sessionId); }, SESSION_INACTIVITY_MS, ); this._inactivityTimers.set(sessionId, timer); } } /** * A single session's Playwright browser connection, page tracking, and * page-matching logic. * * Receives an already-connected {@link Browser} and {@link IBrowserViewGroup} * from the parent {@link PlaywrightService}. Correlates browser view IDs with * Playwright {@link Page} instances by their Chromium target IDs. */ class PlaywrightSession extends Disposable { // --- Page matching --- private readonly _viewIdToPage = new Map(); private readonly _pageToViewId = new WeakMap(); private readonly _tabs = new WeakMap(); private readonly _pageDiscoveryPromises = new Map>(); private readonly _watchedContexts = new WeakSet(); /** In-flight deferred results keyed by their generated ID. */ private readonly _deferredResults = this._register(new DisposableMap; logCtx?: IExecutionLogContext; } & IDisposable>()); constructor( readonly sessionId: string, private _browser: Browser, readonly group: IBrowserViewGroup, private readonly actionScope: IPlaywrightActionScope, private readonly logService: ILogService, private readonly agentNetworkFilterService: IAgentNetworkFilterService, private readonly telemetryService: ITelemetryService, ) { super(); this._register(this.group); this._scanForNewContexts(); } /** Register a disposable to be cleaned up when this session is disposed. */ registerDisposable(d: IDisposable): void { this._register(d); } // --- Page operations --- async waitForPageAndGetSummary(pageId: string, expectedUrl: string, discoveryTimeoutMs: number): Promise { const page = await this._waitForPage(pageId, Date.now() + discoveryTimeoutMs); try { if (expectedUrl !== 'about:blank' && page.url() === 'about:blank') { await page.waitForURL(url => url.toString() !== 'about:blank', { waitUntil: 'domcontentloaded', timeout: OPEN_PAGE_NAVIGATION_TIMEOUT_MS }); } else { await page.waitForLoadState('domcontentloaded', { timeout: OPEN_PAGE_NAVIGATION_TIMEOUT_MS }); } } catch (error) { if (!isNavigationTimeoutError(error)) { throw error; } throw new Error(`Timed out waiting for browser page "${pageId}" to navigate to "${expectedUrl}". The page is open and can be reused.`, { cause: error }); } return this._getSummary(pageId); } async getSummary(pageId: string): Promise { return this._getSummary(pageId, true); } async invokeFunctionRaw(pageId: string, fnDef: string, ...args: unknown[]): Promise { const fn = await this._compileFunction(fnDef); return this._runAgainstPage(pageId, (page) => fn(page, args) as T); } async invokeFunction(pageId: string, fnDef: string, args: unknown[] = [], timeoutMs?: number): Promise { this.logService.info(`[PlaywrightSession] Invoking function on view ${pageId}`); const logCtx: IExecutionLogContext = { startedAt: Date.now(), codeLength: fnDef.length, codeLineCount: fnDef.split('\n').length, pageMethodsCalled: new Map(), wasDeferred: false, resumeCount: 0, logged: false, }; let fn; try { fn = await this._compileFunction(fnDef); } catch (err: unknown) { // Surface compile/syntax errors as { error, summary }, like other execution failures. this._logExecution(logCtx, false); const summary = await this._getSummary(pageId); return { error: err instanceof Error ? err.message : String(err), summary }; } const wrappedCallback = async (page: Page) => fn(createPageApiProxy(page, logCtx.pageMethodsCalled), args); if (timeoutMs !== undefined) { return this._runWithDeferral(pageId, wrappedCallback, timeoutMs, undefined, logCtx); } let result, error; try { result = await this._runAgainstPage(pageId, wrappedCallback); } catch (err: unknown) { error = err instanceof Error ? err.message : String(err); } this._logExecution(logCtx, !error); const summary = await this._getSummary(pageId); return { result, error, summary }; } async waitForDeferredResult(deferredResultId: string, timeoutMs: number): Promise { const entry = this._deferredResults.get(deferredResultId); if (!entry) { throw new Error(`No deferred result found with ID "${deferredResultId}". It may have been cleaned up or already consumed.`); } const { pageId, promise, logCtx } = entry; if (logCtx) { logCtx.resumeCount++; } this._deferredResults.deleteAndDispose(deferredResultId); return this._runWithDeferral(pageId, () => promise, timeoutMs, deferredResultId, logCtx); } async replyToFileChooser(pageId: string, files: string[]): Promise<{ summary: string }> { const page = await this._getPage(pageId); const tab = this._tabs.get(page); if (!tab) { throw new Error('Failed to reply to file chooser'); } await tab.replyToFileChooser(files); const summary = await tab.getSummary(); return { summary }; } async replyToDialog(pageId: string, accept: boolean, promptText?: string): Promise<{ summary: string }> { const page = await this._getPage(pageId); const tab = this._tabs.get(page); if (!tab) { throw new Error('Failed to reply to dialog'); } await tab.replyToDialog(accept, promptText); const summary = await tab.getSummary(); return { summary }; } // --- Private: page operations --- private async _getSummary(pageId: string, full = false): Promise { const page = await this._getPage(pageId); const tab = this._tabs.get(page); if (!tab) { throw new Error('Failed to get page summary'); } return tab.getSummary(full); } private async _runAgainstPage(pageId: string, callback: (page: Page) => T | Promise): Promise { const page = await this._getPage(pageId); const tab = this._tabs.get(page); if (!tab) { throw new Error('Failed to execute function against page'); } return tab.safeRunAgainstPage(async () => callback(page)); } private async _runWithDeferral(pageId: string, callback: (page: Page) => Promise, timeoutMs: number, existingDeferredId?: string, logCtx?: IExecutionLogContext): Promise { const deferred = new DeferredPromise(); deferred.p.catch(() => { /* waitForDeferredResult observes the rejection when resumed */ }); // Attach settlement logging once, on the initiating call: `deferred.p` settles // when the page work finishes no matter how many times the result is deferred, // resumed, or abandoned, so a deferred run is still logged once it settles. // `_logExecution` is idempotent, so this is a no-op if the synchronous path // below already logged a non-deferred completion. if (existingDeferredId === undefined && logCtx) { deferred.p.then(() => this._logExecution(logCtx, true), () => this._logExecution(logCtx, false)); } const wrappedPromise = this._runAgainstPage(pageId, async (page) => { const promise = callback(page); promise.catch(() => { /* prevent unhandled rejection if deferred */ }); deferred.settleWith(promise); return promise; }); let result, error; let interrupted = false; try { result = await raceTimeout(wrappedPromise, timeoutMs, () => { interrupted = true; }); } catch (err: unknown) { if (err instanceof DialogInterruptedError) { interrupted = true; } error = err instanceof Error ? err.message : String(err); } let deferredResultId: string | undefined; if (interrupted) { if (logCtx) { logCtx.wasDeferred = true; } deferredResultId = existingDeferredId ?? generateUuid(); const cleanup = disposableTimeout(() => this._deferredResults.deleteAndDispose(deferredResultId!), DEFERRED_RESULT_CLEANUP_MS); this._deferredResults.set(deferredResultId, { pageId, promise: deferred.p, logCtx, dispose: () => cleanup.dispose() }); this.logService.info(`[PlaywrightSession] Execution interrupted, deferred as ${deferredResultId}`); } else if (logCtx) { // Completed or failed within the timeout: log the outcome now rather than // relying on the settlement promise, which never settles if the page work // threw before `settleWith` ran (e.g. the page could not be resolved). this._logExecution(logCtx, !error); } const summary = await this._getSummary(pageId); return { result, error, summary, deferredResultId }; } /** * Emit completion telemetry for a single {@link invokeFunction} call, once the * page work settles. Idempotent: only the first call for a given context emits, * so the synchronous and settlement-promise paths can both call it safely. */ private _logExecution(ctx: IExecutionLogContext, success: boolean): void { if (ctx.logged) { return; } ctx.logged = true; const entries = [...ctx.pageMethodsCalled.entries()]; const total = entries.reduce((sum, [, count]) => sum + count, 0); this.telemetryService.publicLog2( 'integratedBrowser.tools.runPlaywrightCode.completed', { pageMethodsCalled: JSON.stringify(Object.fromEntries(entries)), pageMethodsCalledDcount: entries.length, pageMethodsCalledCount: total, success: success ? 1 : 0, wasDeferred: ctx.wasDeferred ? 1 : 0, resumeCount: ctx.resumeCount, durationMs: Math.round(Date.now() - ctx.startedAt), codeLength: ctx.codeLength, codeLineCount: ctx.codeLineCount, } ); } private async _compileFunction(fnDef: string): Promise<(page: Page, args: unknown[]) => unknown> { const vm = await import('vm'); return vm.compileFunction(`return (${fnDef})(page, ...args)`, ['page', 'args'], { parsingContext: vm.createContext() }) as (page: Page, args: unknown[]) => unknown; } // --- Private: page matching (view ↔ page pairing) --- private async _getPage(viewId: string): Promise { const page = await this._tryGetPage(viewId); if (page) { return page; } throw new Error(`Page "${viewId}" not found`); } private async _waitForPage(viewId: string, deadline: number): Promise { while (true) { const remaining = deadline - Date.now(); if (remaining <= 0) { throw new Error(`Timed out waiting for browser page "${viewId}" to become available. The page is open and can be reused.`); } const page = await raceTimeout(this._tryGetPage(viewId), remaining); if (page) { return page; } const delay = Math.min(50, deadline - Date.now()); if (delay > 0) { await timeout(delay); } } } private async _tryGetPage(viewId: string): Promise { const resolved = this._viewIdToPage.get(viewId); if (resolved) { return resolved; } this._scanForNewContexts(); await Promise.allSettled([...this._pageDiscoveryPromises.values()]); const discovered = this._viewIdToPage.get(viewId); if (discovered) { return discovered; } return undefined; } private _onPageAdded(page: Page): Promise { const resolved = this._pageToViewId.get(page); if (resolved) { return Promise.resolve(resolved); } const existing = this._pageDiscoveryPromises.get(page); if (existing) { return existing; } const promise = this._resolvePage(page).finally(() => { if (this._pageDiscoveryPromises.get(page) === promise) { this._pageDiscoveryPromises.delete(page); } }); this._pageDiscoveryPromises.set(page, promise); return promise; } private async _resolvePage(page: Page): Promise { page.once('close', () => this._onPageRemoved(page)); page.setDefaultTimeout(10000); this._tabs.set(page, new PlaywrightTab(page, this.actionScope, this.agentNetworkFilterService)); const viewId = await this._getPageViewId(page); if (page.isClosed()) { throw new Error(`Page "${viewId}" closed before it could be resolved`); } this._bindPage(viewId, page); return viewId; } private _onPageRemoved(page: Page): void { this._pageDiscoveryPromises.delete(page); const viewId = this._pageToViewId.get(page); if (viewId) { this._viewIdToPage.delete(viewId); } this._pageToViewId.delete(page); } private async _getPageViewId(page: Page): Promise { const session = await page.context().newCDPSession(page); try { const response = await session.send('Target.getTargetInfo'); const targetInfo: CDPTargetInfo = response.targetInfo; const viewId = targetInfo.vscodeBrowserViewId ?? ''; if (!viewId) { throw new Error(`CDP target ${targetInfo.targetId} is not an integrated browser view`); } return viewId; } finally { try { await session.detach(); } catch (error) { this.logService.warn('[PlaywrightSession] Failed to detach page identity CDP session', error); } } } private _bindPage(viewId: string, page: Page): void { this._viewIdToPage.set(viewId, page); this._pageToViewId.set(page, viewId); this.logService.debug(`[PlaywrightSession] Resolved Playwright page to view ${viewId}`); } private _onContextAdded(context: BrowserContext): void { if (!this._watchedContexts.has(context)) { this._watchedContexts.add(context); context.on('page', page => { void this._onPageAdded(page).catch(error => { this.logService.error('[PlaywrightSession] Failed to resolve page', error); }); }); context.on('close', () => this._watchedContexts.delete(context)); } for (const page of context.pages()) { void this._onPageAdded(page).catch(error => { this.logService.error('[PlaywrightSession] Failed to resolve page', error); }); } } // --- Private: context scanning --- private _scanForNewContexts(): void { for (const context of this._browser.contexts()) { this._onContextAdded(context); } } override dispose(): void { this._browser?.close().catch(() => { /* ignore */ }); super.dispose(); } } function isNavigationTimeoutError(error: unknown): boolean { if (!(error instanceof Error)) { return false; } return error.name === 'TimeoutError' || /Timeout \d+ms exceeded/.test(error.message) || /navigation timeout/i.test(error.message); } /** * Per-invocation state threaded through {@link PlaywrightSession.invokeFunction} * and its deferral machinery so completion telemetry can be emitted exactly once * when the underlying page work settles - even for deferred runs the caller * never resumes. */ interface IExecutionLogContext { /** {@link Date.now} timestamp captured when the invocation began. */ readonly startedAt: number; /** Character length of the executed function source. */ readonly codeLength: number; /** Line count of the executed function source. */ readonly codeLineCount: number; /** Per-method call counts accumulated by {@link createPageApiProxy}. */ readonly pageMethodsCalled: Map; /** Set once the execution is interrupted and deferred at least once. */ wasDeferred: boolean; /** Number of times the caller resumed this execution via {@link PlaywrightSession.waitForDeferredResult}. */ resumeCount: number; /** Guards against double-logging; set by {@link PlaywrightSession._logExecution}. */ logged: boolean; } type RunPlaywrightCodeEvent = { pageMethodsCalled: string; pageMethodsCalledDcount: number; pageMethodsCalledCount: number; success: number; wasDeferred: number; resumeCount: number; durationMs: number; codeLength: number; codeLineCount: number; }; type RunPlaywrightCodeClassification = { pageMethodsCalled: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'JSON object mapping dotted `page.*` method names to their call counts (e.g. `{"click":2,"keyboard.press":5}`), in first-observed order.' }; pageMethodsCalledDcount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of distinct `page.*` methods invoked.' }; pageMethodsCalledCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total `page.*` method calls including duplicates (sum of all per-method counts).' }; success: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: '1 if the code completed without error, 0 otherwise.' }; wasDeferred: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: '1 if the execution was interrupted and deferred at least once, 0 otherwise.' }; resumeCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Number of times the caller resumed this execution by polling for its deferred result. 0 means the run either completed within the first timeout or was deferred and never resumed (settled in the background).' }; durationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Wall-clock time in milliseconds from invocation start until the page work settled.' }; codeLength: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Character length of the executed function source.' }; codeLineCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Line count of the executed function source.' }; owner: 'jruales'; comment: 'Tracks how the run_playwright_code chat tool is exercised.'; }; /** * Property names that are skipped by {@link createPageApiProxy} so that JS * runtime/idiomatic accesses don't show up as fake API usage. Includes * `then`/`catch`/`finally` (so awaiting the proxy never records noise), * conversion hooks, and `constructor`. */ const PAGE_PROXY_IGNORED_PROPS = new Set([ 'then', 'catch', 'finally', 'toJSON', 'toString', 'valueOf', 'constructor', ]); /** * Maximum nesting depth for the recursive page proxy. The Playwright `page` * surface only nests one level deep in practice (e.g. `page.keyboard.press`), * so 3 is generously above any real workload while preventing pathological * cases on cyclic structures. */ const PAGE_PROXY_MAX_DEPTH = 3; /** * Wrap a Playwright `page` so every call through the proxy increments a counter * in {@link methodCalls}, keyed by the dotted path from `page` (e.g. `click`, * `keyboard.press`). Object properties are proxied recursively (capped at * {@link PAGE_PROXY_MAX_DEPTH}) so calls on namespaces like `keyboard` and * `mouse` are visible; symbol keys, `_`-prefixed internals, and * {@link PAGE_PROXY_IGNORED_PROPS} are skipped to avoid noise. * * Wrappers and nested proxies are cached per property so repeated reads return * the same value, preserving Playwright's object identity (e.g. * `page.keyboard === page.keyboard`). */ function createPageApiProxy(target: T, methodCalls: Map, prefix: string = '', depth: number = 0): T { if (depth >= PAGE_PROXY_MAX_DEPTH) { return target; } const cache = new Map(); return new Proxy(target, { get(t, prop, receiver) { const value = Reflect.get(t, prop, receiver); if (typeof prop !== 'string' || prop.startsWith('_') || PAGE_PROXY_IGNORED_PROPS.has(prop)) { return value; } const cached = cache.get(prop); if (cached !== undefined) { return cached; } if (typeof value === 'function') { const name = prefix + prop; const wrapper = function (this: unknown, ...args: unknown[]) { methodCalls.set(name, (methodCalls.get(name) ?? 0) + 1); return Reflect.apply(value as Function, t, args); }; cache.set(prop, wrapper); return wrapper; } if (value !== null && typeof value === 'object') { const nested = createPageApiProxy(value as object, methodCalls, `${prefix}${prop}.`, depth + 1); cache.set(prop, nested); return nested; } return value; }, }); }