Path: blob/main/src/vs/platform/browserView/node/playwrightTab.ts
13397 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45// eslint-disable-next-line local/code-import-patterns6import type * as playwright from 'playwright-core';7import { Emitter, Event } from '../../../base/common/event.js';8import { CancellationToken } from '../../../base/common/cancellation.js';9import { createCancelablePromise, raceCancellablePromises, timeout } from '../../../base/common/async.js';10import { URI } from '../../../base/common/uri.js';11import { IAgentNetworkFilterService } from '../../networkFilter/common/networkFilterService.js';1213type IAiAriaSnapshotOptions = NonNullable<Parameters<playwright.Locator['ariaSnapshot']>[0]> & { _track?: string };1415declare module 'playwright-core' {16interface Page {17// We defined this here to be able to use the unofficial `_track` option18ariaSnapshot(options?: IAiAriaSnapshotOptions): Promise<string>;19}20}2122/**23* Thrown when a dialog (alert, confirm, prompt) opens while a page action is24* running. The caller should defer the underlying promise and let the agent25* handle the dialog before retrying.26*/27export class DialogInterruptedError extends Error {28constructor() {29super('Action was interrupted by a dialog');30this.name = 'DialogInterruptedError';31}32}3334/**35* Wrapper around a Playwright page that tracks additional state like active dialogs and recent console messages,36* and can produce a summary of the page's current state for use in tools.37*38* Loosely based on https://github.com/microsoft/playwright/blob/main/packages/playwright/src/mcp/browser/tab.ts.39*/40export class PlaywrightTab {41private _onDialogStateChanged = new Emitter<void>();4243private _dialog: playwright.Dialog | undefined;44private _fileChooser: playwright.FileChooser | undefined;45private _logs: { type: string; time: number; description: string }[] = [];46private _needsFullSnapshot = false;4748private _initialized: Promise<void>;4950constructor(51/**52* @deprecated prefer accessing the page via safeRunAgainstPage.53* Only use this directly if you are sure it cannot be blocked by dialogs.54*/55private readonly page: playwright.Page,56private readonly agentNetworkFilterService: IAgentNetworkFilterService,57) {58page.on('console', event => this._handleConsoleMessage(event))59.on('pageerror', error => this._handlePageError(error))60.on('requestfailed', request => this._handleRequestFailed(request))61.on('dialog', dialog => this._handleDialog(dialog))62.on('download', download => this._handleDownload(download));6364this._initialized = this._initialize();65}6667private async _initialize() {68const messages = await this.page.consoleMessages().catch(() => []);69for (const message of messages) { this._handleConsoleMessage(message); }70const errors = await this.page.pageErrors().catch(() => []);71for (const error of errors) { this._handlePageError(error); }72}7374private _handleDialog(dialog: playwright.Dialog) {75this._dialog = dialog;76// Playwright doesn't give us an event for when a dialog is closed, so we run a no-op script to know when it closes.77this.page.waitForFunction(() => true, undefined, { timeout: 0 }).then(() => {78if (this._dialog === dialog) {79this._dialog = undefined;80this._onDialogStateChanged.fire();81}82});83this._onDialogStateChanged.fire();84}8586async replyToDialog(accept?: boolean, promptText?: string) {87if (!this._dialog) {88throw new Error('No active modal dialog to respond to');89}90const dialog = this._dialog;91this._dialog = undefined;92this._onDialogStateChanged.fire();93await this.safeRunAgainstPage(async () => {94if (accept) {95await dialog.accept(promptText);96} else {97await dialog.dismiss();98}99});100}101102private _handleFileChooser(chooser: playwright.FileChooser) {103this._fileChooser = chooser;104}105106async replyToFileChooser(files: string[]) {107if (!this._fileChooser) {108throw new Error('No active file chooser dialog to respond to');109}110const chooser = this._fileChooser;111this._fileChooser = undefined;112await this.safeRunAgainstPage(() => chooser.setFiles(files));113}114115private async _handleDownload(download: playwright.Download) {116this._logs.push({ type: 'download', time: Date.now(), description: `${download.suggestedFilename()}` });117}118119private _handleRequestFailed(request: playwright.Request) {120const timing = request.timing();121this._logs.push({ type: 'requestFailed', time: timing.responseEnd + timing.startTime, description: `${request.method()} request to ${request.url()} failed: "${request.failure()?.errorText}"` });122}123124private _handleConsoleMessage(message: playwright.ConsoleMessage) {125if (message.type() === 'error' || message.type() === 'warning') {126this._logs.push({ type: 'console', time: message.timestamp(), description: `[${message.type()}] ${message.text()}` });127}128}129130private _handlePageError(error: Error) {131this._logs.push({ type: 'pageError', time: Date.now(), description: error.stack ?? error.message });132}133134/**135* Returns a blocked-by-policy error message if the current page URL is136* denied by the network filter, or `undefined` if the URL is allowed.137*/138private _getBlockedURLErrorMessage(): string | undefined {139const url = this.page.url();140if (!url || url === 'about:blank') {141return undefined;142}143let uri: URI | undefined;144try { uri = URI.parse(url); } catch { }145if (uri && !this.agentNetworkFilterService.isUriAllowed(uri)) {146return this.agentNetworkFilterService.formatError(uri);147}148return undefined;149}150151/**152* Run a callback against the page and wait for it to complete.153*154* Because dialogs pause the page, execution races against any dialog that opens -- if a dialog155* appears before the callback finishes, the method throws so the caller can surface it to the agent.156*157* Also allows for interactions to be handled differently when triggered by agents.158* E.g. file dialogs should appear when the user triggers one, but not when the agent does.159*/160async safeRunAgainstPage<T>(action: (page: playwright.Page, token: CancellationToken) => Promise<T>): Promise<T> {161if (this._dialog) {162throw new Error(`Cannot perform action while a dialog is open`);163}164165// Block agent actions when the current page URL is on the deny list.166const blockedError = this._getBlockedURLErrorMessage();167if (blockedError) {168throw new Error(blockedError);169}170171let actionDidComplete = false;172let result: T | void;173const dialogOpened = Event.toPromise(this._onDialogStateChanged.event);174const actionCompleted = createCancelablePromise(async (token) => {175176// Whenever the page has a `filechooser` handler, the default file chooser is disabled.177// We don't want this during normal user interactions, but we do for agentic interactions.178// So we add a handler just during the action, and remove it afterwards.179// This isn't perfect (e.g. the user could trigger it while an action is running), but it's a best effort.180const handleFileChooser = (chooser: playwright.FileChooser) => this._handleFileChooser(chooser);181this.page.on('filechooser', handleFileChooser);182183try {184result = await this.runAndWaitForCompletion((token) => action(this.page, token), token);185actionDidComplete = true;186} finally {187this.page.off('filechooser', handleFileChooser);188}189});190191return raceCancellablePromises([dialogOpened, actionCompleted]).then(() => {192if (!actionDidComplete) {193// A dialog was opened before the action completed. Note we don't cancel the action, just ignore its result.194throw new DialogInterruptedError();195}196return result!;197});198}199200async getSummary(full = this._needsFullSnapshot): Promise<string> {201await this._initialized;202203// When the current page URL is blocked by network policy, return only a204// policy error — do not expose title, URL, console logs, or snapshot to205// avoid prompt-injection via blocked content.206const blockedError = this._getBlockedURLErrorMessage();207if (blockedError) {208return blockedError;209}210211if (full && this._needsFullSnapshot) {212this._needsFullSnapshot = false;213}214215const snapshotFromPage = await this.safeRunAgainstPage((page) => this.getAiSnapshot(page, full)).catch(() => {216this._needsFullSnapshot = true;217return undefined;218});219const title = await this.safeRunAgainstPage((page) => page.title()).catch(() => '');220221const logs = this._logs;222this._logs = [];223224const snapshot = snapshotFromPage?.trim() ?? '';225226return [227...(title ? [`Page Title: ${title}`] : []),228`URL: ${this.page.url()}`,229...(this._dialog ? [`Active ${this._dialog.type()} dialog: "${this._dialog.message()}"`] : []),230...(this._fileChooser ? [`Active file chooser dialog`] : []),231...(logs.length > 0 ? [232`Recent events:`,233...logs.map(log => `- [${new Date(log.time).toISOString()}] (${log.type}) ${log.description}`)234] : []),235`Snapshot: ${snapshotFromPage !== undefined ? snapshot ? `\n${snapshot}` : '<unchanged>' : '<unavailable>'}`,236].join('\n');237}238239private getAiSnapshot(page: playwright.Page, full: boolean): Promise<string> {240const options: IAiAriaSnapshotOptions = { mode: 'ai' };241if (!full) {242options._track = 'response';243}244return page.ariaSnapshot(options);245}246247private async runAndWaitForCompletion<T>(callback: (token: CancellationToken) => Promise<T>, token = CancellationToken.None): Promise<T> {248const requests: playwright.Request[] = [];249250const requestListener = (request: playwright.Request) => requests.push(request);251const disposeListeners = () => {252this.page.off('request', requestListener);253};254this.page.on('request', requestListener);255256let result: T;257try {258result = await callback(token);259} finally {260disposeListeners();261}262263const requestedNavigation = requests.some(request => request.isNavigationRequest());264if (requestedNavigation) {265await this.page.mainFrame().waitForLoadState('load', { timeout: 10000 }).catch(() => { });266return result;267}268269const promises: Promise<unknown>[] = [];270for (const request of requests) {271if (['document', 'stylesheet', 'script', 'xhr', 'fetch'].includes(request.resourceType())) { promises.push(request.response().then(r => r?.finished()).catch(() => { })); }272else { promises.push(request.response().catch(() => { })); }273}274await raceCancellablePromises<unknown>([275Promise.all(promises),276timeout(5000) // Don't wait indefinitely for requests to finish277]);278279return result;280}281}282283284