Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/browserView/node/playwrightTab.ts
13397 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
// eslint-disable-next-line local/code-import-patterns
7
import type * as playwright from 'playwright-core';
8
import { Emitter, Event } from '../../../base/common/event.js';
9
import { CancellationToken } from '../../../base/common/cancellation.js';
10
import { createCancelablePromise, raceCancellablePromises, timeout } from '../../../base/common/async.js';
11
import { URI } from '../../../base/common/uri.js';
12
import { IAgentNetworkFilterService } from '../../networkFilter/common/networkFilterService.js';
13
14
type IAiAriaSnapshotOptions = NonNullable<Parameters<playwright.Locator['ariaSnapshot']>[0]> & { _track?: string };
15
16
declare module 'playwright-core' {
17
interface Page {
18
// We defined this here to be able to use the unofficial `_track` option
19
ariaSnapshot(options?: IAiAriaSnapshotOptions): Promise<string>;
20
}
21
}
22
23
/**
24
* Thrown when a dialog (alert, confirm, prompt) opens while a page action is
25
* running. The caller should defer the underlying promise and let the agent
26
* handle the dialog before retrying.
27
*/
28
export class DialogInterruptedError extends Error {
29
constructor() {
30
super('Action was interrupted by a dialog');
31
this.name = 'DialogInterruptedError';
32
}
33
}
34
35
/**
36
* Wrapper around a Playwright page that tracks additional state like active dialogs and recent console messages,
37
* and can produce a summary of the page's current state for use in tools.
38
*
39
* Loosely based on https://github.com/microsoft/playwright/blob/main/packages/playwright/src/mcp/browser/tab.ts.
40
*/
41
export class PlaywrightTab {
42
private _onDialogStateChanged = new Emitter<void>();
43
44
private _dialog: playwright.Dialog | undefined;
45
private _fileChooser: playwright.FileChooser | undefined;
46
private _logs: { type: string; time: number; description: string }[] = [];
47
private _needsFullSnapshot = false;
48
49
private _initialized: Promise<void>;
50
51
constructor(
52
/**
53
* @deprecated prefer accessing the page via safeRunAgainstPage.
54
* Only use this directly if you are sure it cannot be blocked by dialogs.
55
*/
56
private readonly page: playwright.Page,
57
private readonly agentNetworkFilterService: IAgentNetworkFilterService,
58
) {
59
page.on('console', event => this._handleConsoleMessage(event))
60
.on('pageerror', error => this._handlePageError(error))
61
.on('requestfailed', request => this._handleRequestFailed(request))
62
.on('dialog', dialog => this._handleDialog(dialog))
63
.on('download', download => this._handleDownload(download));
64
65
this._initialized = this._initialize();
66
}
67
68
private async _initialize() {
69
const messages = await this.page.consoleMessages().catch(() => []);
70
for (const message of messages) { this._handleConsoleMessage(message); }
71
const errors = await this.page.pageErrors().catch(() => []);
72
for (const error of errors) { this._handlePageError(error); }
73
}
74
75
private _handleDialog(dialog: playwright.Dialog) {
76
this._dialog = dialog;
77
// 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.
78
this.page.waitForFunction(() => true, undefined, { timeout: 0 }).then(() => {
79
if (this._dialog === dialog) {
80
this._dialog = undefined;
81
this._onDialogStateChanged.fire();
82
}
83
});
84
this._onDialogStateChanged.fire();
85
}
86
87
async replyToDialog(accept?: boolean, promptText?: string) {
88
if (!this._dialog) {
89
throw new Error('No active modal dialog to respond to');
90
}
91
const dialog = this._dialog;
92
this._dialog = undefined;
93
this._onDialogStateChanged.fire();
94
await this.safeRunAgainstPage(async () => {
95
if (accept) {
96
await dialog.accept(promptText);
97
} else {
98
await dialog.dismiss();
99
}
100
});
101
}
102
103
private _handleFileChooser(chooser: playwright.FileChooser) {
104
this._fileChooser = chooser;
105
}
106
107
async replyToFileChooser(files: string[]) {
108
if (!this._fileChooser) {
109
throw new Error('No active file chooser dialog to respond to');
110
}
111
const chooser = this._fileChooser;
112
this._fileChooser = undefined;
113
await this.safeRunAgainstPage(() => chooser.setFiles(files));
114
}
115
116
private async _handleDownload(download: playwright.Download) {
117
this._logs.push({ type: 'download', time: Date.now(), description: `${download.suggestedFilename()}` });
118
}
119
120
private _handleRequestFailed(request: playwright.Request) {
121
const timing = request.timing();
122
this._logs.push({ type: 'requestFailed', time: timing.responseEnd + timing.startTime, description: `${request.method()} request to ${request.url()} failed: "${request.failure()?.errorText}"` });
123
}
124
125
private _handleConsoleMessage(message: playwright.ConsoleMessage) {
126
if (message.type() === 'error' || message.type() === 'warning') {
127
this._logs.push({ type: 'console', time: message.timestamp(), description: `[${message.type()}] ${message.text()}` });
128
}
129
}
130
131
private _handlePageError(error: Error) {
132
this._logs.push({ type: 'pageError', time: Date.now(), description: error.stack ?? error.message });
133
}
134
135
/**
136
* Returns a blocked-by-policy error message if the current page URL is
137
* denied by the network filter, or `undefined` if the URL is allowed.
138
*/
139
private _getBlockedURLErrorMessage(): string | undefined {
140
const url = this.page.url();
141
if (!url || url === 'about:blank') {
142
return undefined;
143
}
144
let uri: URI | undefined;
145
try { uri = URI.parse(url); } catch { }
146
if (uri && !this.agentNetworkFilterService.isUriAllowed(uri)) {
147
return this.agentNetworkFilterService.formatError(uri);
148
}
149
return undefined;
150
}
151
152
/**
153
* Run a callback against the page and wait for it to complete.
154
*
155
* Because dialogs pause the page, execution races against any dialog that opens -- if a dialog
156
* appears before the callback finishes, the method throws so the caller can surface it to the agent.
157
*
158
* Also allows for interactions to be handled differently when triggered by agents.
159
* E.g. file dialogs should appear when the user triggers one, but not when the agent does.
160
*/
161
async safeRunAgainstPage<T>(action: (page: playwright.Page, token: CancellationToken) => Promise<T>): Promise<T> {
162
if (this._dialog) {
163
throw new Error(`Cannot perform action while a dialog is open`);
164
}
165
166
// Block agent actions when the current page URL is on the deny list.
167
const blockedError = this._getBlockedURLErrorMessage();
168
if (blockedError) {
169
throw new Error(blockedError);
170
}
171
172
let actionDidComplete = false;
173
let result: T | void;
174
const dialogOpened = Event.toPromise(this._onDialogStateChanged.event);
175
const actionCompleted = createCancelablePromise(async (token) => {
176
177
// Whenever the page has a `filechooser` handler, the default file chooser is disabled.
178
// We don't want this during normal user interactions, but we do for agentic interactions.
179
// So we add a handler just during the action, and remove it afterwards.
180
// This isn't perfect (e.g. the user could trigger it while an action is running), but it's a best effort.
181
const handleFileChooser = (chooser: playwright.FileChooser) => this._handleFileChooser(chooser);
182
this.page.on('filechooser', handleFileChooser);
183
184
try {
185
result = await this.runAndWaitForCompletion((token) => action(this.page, token), token);
186
actionDidComplete = true;
187
} finally {
188
this.page.off('filechooser', handleFileChooser);
189
}
190
});
191
192
return raceCancellablePromises([dialogOpened, actionCompleted]).then(() => {
193
if (!actionDidComplete) {
194
// A dialog was opened before the action completed. Note we don't cancel the action, just ignore its result.
195
throw new DialogInterruptedError();
196
}
197
return result!;
198
});
199
}
200
201
async getSummary(full = this._needsFullSnapshot): Promise<string> {
202
await this._initialized;
203
204
// When the current page URL is blocked by network policy, return only a
205
// policy error — do not expose title, URL, console logs, or snapshot to
206
// avoid prompt-injection via blocked content.
207
const blockedError = this._getBlockedURLErrorMessage();
208
if (blockedError) {
209
return blockedError;
210
}
211
212
if (full && this._needsFullSnapshot) {
213
this._needsFullSnapshot = false;
214
}
215
216
const snapshotFromPage = await this.safeRunAgainstPage((page) => this.getAiSnapshot(page, full)).catch(() => {
217
this._needsFullSnapshot = true;
218
return undefined;
219
});
220
const title = await this.safeRunAgainstPage((page) => page.title()).catch(() => '');
221
222
const logs = this._logs;
223
this._logs = [];
224
225
const snapshot = snapshotFromPage?.trim() ?? '';
226
227
return [
228
...(title ? [`Page Title: ${title}`] : []),
229
`URL: ${this.page.url()}`,
230
...(this._dialog ? [`Active ${this._dialog.type()} dialog: "${this._dialog.message()}"`] : []),
231
...(this._fileChooser ? [`Active file chooser dialog`] : []),
232
...(logs.length > 0 ? [
233
`Recent events:`,
234
...logs.map(log => `- [${new Date(log.time).toISOString()}] (${log.type}) ${log.description}`)
235
] : []),
236
`Snapshot: ${snapshotFromPage !== undefined ? snapshot ? `\n${snapshot}` : '<unchanged>' : '<unavailable>'}`,
237
].join('\n');
238
}
239
240
private getAiSnapshot(page: playwright.Page, full: boolean): Promise<string> {
241
const options: IAiAriaSnapshotOptions = { mode: 'ai' };
242
if (!full) {
243
options._track = 'response';
244
}
245
return page.ariaSnapshot(options);
246
}
247
248
private async runAndWaitForCompletion<T>(callback: (token: CancellationToken) => Promise<T>, token = CancellationToken.None): Promise<T> {
249
const requests: playwright.Request[] = [];
250
251
const requestListener = (request: playwright.Request) => requests.push(request);
252
const disposeListeners = () => {
253
this.page.off('request', requestListener);
254
};
255
this.page.on('request', requestListener);
256
257
let result: T;
258
try {
259
result = await callback(token);
260
} finally {
261
disposeListeners();
262
}
263
264
const requestedNavigation = requests.some(request => request.isNavigationRequest());
265
if (requestedNavigation) {
266
await this.page.mainFrame().waitForLoadState('load', { timeout: 10000 }).catch(() => { });
267
return result;
268
}
269
270
const promises: Promise<unknown>[] = [];
271
for (const request of requests) {
272
if (['document', 'stylesheet', 'script', 'xhr', 'fetch'].includes(request.resourceType())) { promises.push(request.response().then(r => r?.finished()).catch(() => { })); }
273
else { promises.push(request.response().catch(() => { })); }
274
}
275
await raceCancellablePromises<unknown>([
276
Promise.all(promises),
277
timeout(5000) // Don't wait indefinitely for requests to finish
278
]);
279
280
return result;
281
}
282
}
283
284