Files
vscode/src/vs/platform/browserView/node/playwrightService.ts
T

796 lines
30 KiB
TypeScript

/*---------------------------------------------------------------------------------------------
* 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<CDPRequest>;
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<string, PlaywrightSession>());
/** In-flight session initializations keyed by session ID. */
private readonly _pendingInits = new Map<string, Promise<PlaywrightSession>>();
/** Inactivity timers keyed by session ID. */
private readonly _inactivityTimers = this._register(new DisposableMap<string, IDisposable>());
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<PlaywrightSession> {
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<PlaywrightSession> {
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<string> {
const session = await this._getOrCreateSession(sessionId);
return session.waitForPageAndGetSummary(pageId, expectedUrl, discoveryTimeoutMs);
}
async getSummary(sessionId: string, pageId: string): Promise<string> {
const session = await this._getOrCreateSession(sessionId);
return session.getSummary(pageId);
}
async invokeFunctionRaw<T>(sessionId: string, pageId: string, fnDef: string, ...args: unknown[]): Promise<T> {
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<IInvokeFunctionResult> {
const session = await this._getOrCreateSession(sessionId);
return session.invokeFunction(pageId, fnDef, args, timeoutMs);
}
async waitForDeferredResult(sessionId: string, deferredResultId: string, timeoutMs: number): Promise<IInvokeFunctionResult> {
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<void> {
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<string, Page>();
private readonly _pageToViewId = new WeakMap<Page, string>();
private readonly _tabs = new WeakMap<Page, PlaywrightTab>();
private readonly _pageDiscoveryPromises = new Map<Page, Promise<string>>();
private readonly _watchedContexts = new WeakSet<BrowserContext>();
/** In-flight deferred results keyed by their generated ID. */
private readonly _deferredResults = this._register(new DisposableMap<string, {
pageId: string;
promise: Promise<unknown>;
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<string> {
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<string> {
return this._getSummary(pageId, true);
}
async invokeFunctionRaw<T>(pageId: string, fnDef: string, ...args: unknown[]): Promise<T> {
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<IInvokeFunctionResult> {
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<string, number>(),
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<IInvokeFunctionResult> {
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<string> {
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<T>(pageId: string, callback: (page: Page) => T | Promise<T>): Promise<T> {
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<unknown>, timeoutMs: number, existingDeferredId?: string, logCtx?: IExecutionLogContext): Promise<IInvokeFunctionResult> {
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<RunPlaywrightCodeEvent, RunPlaywrightCodeClassification>(
'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<Page> {
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<Page> {
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<Page | undefined> {
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<string> {
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<string> {
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<string> {
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<string, number>;
/** 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<string>([
'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<T extends object>(target: T, methodCalls: Map<string, number>, prefix: string = '', depth: number = 0): T {
if (depth >= PAGE_PROXY_MAX_DEPTH) {
return target;
}
const cache = new Map<string, unknown>();
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;
},
});
}