Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/browserView/node/playwrightService.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
import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js';
7
import { DeferredPromise, disposableTimeout, raceTimeout } from '../../../base/common/async.js';
8
import { Emitter, Event } from '../../../base/common/event.js';
9
import { ILogService } from '../../log/common/log.js';
10
import { IAgentNetworkFilterService } from '../../networkFilter/common/networkFilterService.js';
11
import { IInvokeFunctionResult, IPlaywrightService } from '../common/playwrightService.js';
12
import { IBrowserViewGroupRemoteService } from '../node/browserViewGroupRemoteService.js';
13
import { IBrowserViewGroup } from '../common/browserViewGroup.js';
14
import { PlaywrightTab, DialogInterruptedError } from './playwrightTab.js';
15
import { CDPEvent, CDPRequest, CDPResponse } from '../common/cdp/types.js';
16
import { generateUuid } from '../../../base/common/uuid.js';
17
18
// eslint-disable-next-line local/code-import-patterns
19
import type { Browser, BrowserContext, Page } from 'playwright-core';
20
21
interface PlaywrightTransport {
22
send(s: CDPRequest): void;
23
close(): void; // Note: calling close is expected to issue onclose at some point.
24
onmessage?: (message: CDPResponse | CDPEvent) => void;
25
onclose?: (reason?: string) => void;
26
}
27
28
declare module 'playwright-core' {
29
interface BrowserType {
30
_connectOverCDPTransport(transport: PlaywrightTransport): Promise<Browser>;
31
}
32
}
33
34
const DEFERRED_RESULT_CLEANUP_MS = 5 * 60_000; // 5 minutes
35
36
/**
37
* Shared-process implementation of {@link IPlaywrightService}.
38
*
39
* Creates a {@link PlaywrightPageManager} eagerly on construction to track
40
* browser views. The Playwright browser connection is lazily initialised
41
* only when an operation that requires it is called.
42
*/
43
export class PlaywrightService extends Disposable implements IPlaywrightService {
44
declare readonly _serviceBrand: undefined;
45
46
private readonly _pages: PlaywrightPageManager;
47
readonly onDidChangeTrackedPages: Event<readonly string[]>;
48
49
private _browser: Browser | undefined;
50
private _initPromise: Promise<void> | undefined;
51
52
/** In-flight deferred results keyed by their generated ID. */
53
private readonly _deferredResults = this._register(new DisposableMap<string, {
54
pageId: string;
55
promise: Promise<unknown>;
56
} & IDisposable>());
57
58
constructor(
59
private readonly windowId: number,
60
private readonly browserViewGroupRemoteService: IBrowserViewGroupRemoteService,
61
private readonly logService: ILogService,
62
agentNetworkFilterService: IAgentNetworkFilterService,
63
) {
64
super();
65
this._pages = this._register(new PlaywrightPageManager(logService, agentNetworkFilterService));
66
this.onDidChangeTrackedPages = this._pages.onDidChangeTrackedPages;
67
}
68
69
// --- Page tracking (delegated to manager) ---
70
71
async startTrackingPage(viewId: string): Promise<void> {
72
return this._pages.startTrackingPage(viewId);
73
}
74
75
async stopTrackingPage(viewId: string): Promise<void> {
76
return this._pages.stopTrackingPage(viewId);
77
}
78
79
async isPageTracked(viewId: string): Promise<boolean> {
80
return this._pages.isPageTracked(viewId);
81
}
82
83
async getTrackedPages(): Promise<readonly string[]> {
84
return this._pages.getTrackedPages();
85
}
86
87
// --- Playwright operations (lazy init) ---
88
89
/**
90
* Ensure the Playwright browser connection is initialized and the page
91
* manager is wired up to the browser view group.
92
*/
93
private async initialize(): Promise<void> {
94
if (this._browser) {
95
return;
96
}
97
98
if (this._initPromise) {
99
return this._initPromise;
100
}
101
102
this._initPromise = (async () => {
103
try {
104
this.logService.debug('[PlaywrightService] Creating browser view group');
105
const group = await this.browserViewGroupRemoteService.createGroup({ mainWindowId: this.windowId });
106
107
this.logService.debug('[PlaywrightService] Connecting to browser via CDP');
108
const playwright = await import('playwright-core');
109
const sub = group.onCDPMessage(msg => transport.onmessage?.(msg));
110
const transport: PlaywrightTransport = {
111
close() {
112
sub.dispose();
113
this.onclose?.();
114
},
115
send(message) {
116
void group.sendCDPMessage(message);
117
}
118
};
119
const browser = await playwright.chromium._connectOverCDPTransport(transport);
120
121
this.logService.debug('[PlaywrightService] Connected to browser');
122
123
// This can happen if the service was disposed while we were waiting for the connection. In that case, clean up immediately.
124
if (this._initPromise === undefined) {
125
browser.close().catch(() => { /* ignore */ });
126
group.dispose();
127
throw new Error('PlaywrightService was disposed during initialization');
128
}
129
130
browser.on('disconnected', () => {
131
this.logService.debug('[PlaywrightService] Browser disconnected');
132
if (this._browser === browser) {
133
this._pages.reset();
134
this._browser = undefined;
135
this._initPromise = undefined;
136
}
137
});
138
139
await this._pages.initialize(browser, group);
140
this._browser = browser;
141
} catch (e) {
142
this._initPromise = undefined;
143
throw e;
144
}
145
})();
146
147
return this._initPromise;
148
}
149
150
async openPage(url: string): Promise<{ pageId: string; summary: string }> {
151
await this.initialize();
152
const pageId = await this._pages.newPage(url);
153
const summary = await this._pages.getSummary(pageId);
154
return { pageId, summary };
155
}
156
157
async getSummary(pageId: string): Promise<string> {
158
await this.initialize();
159
return this._pages.getSummary(pageId, true);
160
}
161
162
async invokeFunctionRaw<T>(pageId: string, fnDef: string, ...args: unknown[]): Promise<T> {
163
await this.initialize();
164
165
const vm = await import('vm');
166
const fn = vm.compileFunction(`return (${fnDef})(page, ...args)`, ['page', 'args'], { parsingContext: vm.createContext() });
167
168
return this._pages.runAgainstPage(pageId, (page) => fn(page, args));
169
}
170
171
private async invokeFunctionWithDeferral<T>(pageId: string, fnDef: string, args: unknown[], timeoutMs: number): Promise<IInvokeFunctionResult> {
172
await this.initialize();
173
174
const vm = await import('vm');
175
const fn = vm.compileFunction(`return (${fnDef})(page, ...args)`, ['page', 'args'], { parsingContext: vm.createContext() });
176
177
return this._runWithDeferral(pageId, (page) => fn(page, args ?? []), timeoutMs);
178
}
179
180
async invokeFunction(pageId: string, fnDef: string, args: unknown[] = [], timeoutMs?: number): Promise<IInvokeFunctionResult> {
181
this.logService.info(`[PlaywrightService] Invoking function on view ${pageId}`);
182
183
if (timeoutMs !== undefined) {
184
return this.invokeFunctionWithDeferral(pageId, fnDef, args, timeoutMs);
185
}
186
187
let result, error;
188
try {
189
result = await this.invokeFunctionRaw(pageId, fnDef, ...args);
190
} catch (err: unknown) {
191
error = err instanceof Error ? err.message : String(err);
192
}
193
194
const summary = await this._pages.getSummary(pageId);
195
196
return { result, error, summary };
197
}
198
199
async waitForDeferredResult(deferredResultId: string, timeoutMs: number): Promise<IInvokeFunctionResult> {
200
const entry = this._deferredResults.get(deferredResultId);
201
if (!entry) {
202
throw new Error(`No deferred result found with ID "${deferredResultId}". It may have been cleaned up or already consumed.`);
203
}
204
205
const { pageId, promise } = entry;
206
// Remove eagerly — _runWithDeferral will re-insert if interrupted again.
207
this._deferredResults.deleteAndDispose(deferredResultId);
208
209
// The callback ignores the page param since execution is already in-flight.
210
return this._runWithDeferral(pageId, () => promise, timeoutMs, deferredResultId);
211
}
212
213
/**
214
* Run a callback against a page with deferred result support.
215
*/
216
private async _runWithDeferral(pageId: string, callback: (page: Page) => Promise<unknown>, timeoutMs: number, existingDeferredId?: string): Promise<IInvokeFunctionResult> {
217
const effectiveTimeout = timeoutMs;
218
219
// Start execution via safeRunAgainstPage, but capture the raw promise
220
// independently so it can be deferred if a dialog or timeout interrupts.
221
const deferred = new DeferredPromise();
222
const wrappedPromise = this._pages.runAgainstPage(pageId, async (page) => {
223
const promise = callback(page);
224
promise.catch(() => { /* prevent unhandled rejection if deferred */ });
225
deferred.settleWith(promise);
226
return promise;
227
});
228
229
let result, error;
230
let interrupted = false;
231
232
try {
233
result = await raceTimeout(wrappedPromise, effectiveTimeout, () => { interrupted = true; });
234
} catch (err: unknown) {
235
if (err instanceof DialogInterruptedError) {
236
interrupted = true;
237
}
238
error = err instanceof Error ? err.message : String(err);
239
}
240
241
let deferredResultId: string | undefined;
242
if (interrupted) {
243
deferredResultId = existingDeferredId ?? generateUuid();
244
const cleanup = disposableTimeout(() => this._deferredResults.deleteAndDispose(deferredResultId!), DEFERRED_RESULT_CLEANUP_MS);
245
this._deferredResults.set(deferredResultId, { pageId, promise: deferred.p, dispose: () => cleanup.dispose() });
246
247
this.logService.info(`[PlaywrightService] Execution interrupted, deferred as ${deferredResultId}`);
248
}
249
250
const summary = await this._pages.getSummary(pageId);
251
return { result, error, summary, deferredResultId };
252
}
253
254
async replyToFileChooser(pageId: string, files: string[]): Promise<{ summary: string }> {
255
await this.initialize();
256
const summary = await this._pages.replyToFileChooser(pageId, files);
257
return { summary };
258
}
259
260
async replyToDialog(pageId: string, accept: boolean, promptText?: string): Promise<{ summary: string }> {
261
await this.initialize();
262
const summary = await this._pages.replyToDialog(pageId, accept, promptText);
263
return { summary };
264
}
265
266
override dispose(): void {
267
if (this._browser) {
268
this._browser.close().catch(() => { /* ignore */ });
269
this._browser = undefined;
270
}
271
this._initPromise = undefined;
272
super.dispose();
273
}
274
}
275
276
/**
277
* Manages page tracking and correlates browser view IDs with Playwright
278
* {@link Page} instances.
279
*
280
* Created eagerly by {@link PlaywrightService} and operates in two phases:
281
*
282
* 1. **Before initialization** - tracks which pages are added/removed but
283
* cannot resolve Playwright {@link Page} objects.
284
* 2. **After {@link initialize}** - proxies add/remove calls to the
285
* {@link IBrowserViewGroup} and pairs view IDs with Playwright pages
286
* via FIFO matching of the group's IPC events and Playwright's CDP events.
287
*
288
* A periodic scan handles the case where Playwright creates a new
289
* {@link BrowserContext} for a target whose session was previously unknown.
290
*/
291
class PlaywrightPageManager extends Disposable {
292
293
// --- Page tracking ---
294
295
private readonly _trackedPages = new Set<string>();
296
297
private readonly _onDidChangeTrackedPages = this._register(new Emitter<readonly string[]>());
298
readonly onDidChangeTrackedPages: Event<readonly string[]> = this._onDidChangeTrackedPages.event;
299
300
// --- Page matching ---
301
302
private readonly _viewIdToPage = new Map<string, Page>();
303
private readonly _pageToViewId = new WeakMap<Page, string>();
304
private readonly _tabs = new WeakMap<Page, PlaywrightTab>();
305
306
/** View IDs received from the group but not yet matched with a page. */
307
private _viewIdQueue: Array<{
308
viewId: string;
309
page: DeferredPromise<Page>;
310
}> = [];
311
312
/** Pages received from Playwright but not yet matched with a view ID. */
313
private _pageQueue: Array<{
314
page: Page;
315
viewId: DeferredPromise<string>;
316
}> = [];
317
318
private readonly _watchedContexts = new WeakSet<BrowserContext>();
319
private _scanTimer: ReturnType<typeof setInterval> | undefined;
320
321
// --- Initialized state ---
322
323
private readonly _initStore = this._register(new DisposableStore());
324
private _group: IBrowserViewGroup | undefined;
325
private _browser: Browser | undefined;
326
private _openContext: BrowserContext | undefined = undefined;
327
328
constructor(
329
private readonly logService: ILogService,
330
private readonly agentNetworkFilterService: IAgentNetworkFilterService,
331
) {
332
super();
333
}
334
335
// --- Public: page tracking ---
336
337
isPageTracked(viewId: string): boolean {
338
return this._trackedPages.has(viewId);
339
}
340
341
getTrackedPages(): readonly string[] {
342
return [...this._trackedPages];
343
}
344
345
async startTrackingPage(viewId: string): Promise<void> {
346
if (this._trackedPages.has(viewId)) {
347
return;
348
}
349
350
this._trackedPages.add(viewId);
351
this._fireTrackedPagesChanged();
352
353
if (this._group) {
354
await this._addPageToGroup(viewId);
355
}
356
}
357
358
async stopTrackingPage(viewId: string): Promise<void> {
359
if (!this._trackedPages.has(viewId)) {
360
return;
361
}
362
363
this._trackedPages.delete(viewId);
364
this._fireTrackedPagesChanged();
365
366
if (this._group) {
367
await this._removePageFromGroup(viewId);
368
}
369
}
370
371
// --- Public: Playwright operations (require initialization) ---
372
373
/**
374
* Create a new page in the browser and return its associated page ID.
375
* The page is automatically added to the tracked set.
376
*/
377
async newPage(url: string): Promise<string> {
378
if (!this._browser) {
379
throw new Error('PlaywrightPageManager has not been initialized');
380
}
381
382
if (!this._openContext) {
383
this._openContext = await this._browser.newContext();
384
this.onContextAdded(this._openContext);
385
}
386
387
const page = await this._openContext.newPage();
388
389
const viewId = await this.onPageAdded(page);
390
391
this._trackedPages.add(viewId);
392
this._fireTrackedPagesChanged();
393
394
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
395
396
return viewId;
397
}
398
399
async runAgainstPage<T>(pageId: string, callback: (page: Page) => T | Promise<T>): Promise<T> {
400
const page = await this.getPage(pageId);
401
const tab = this._tabs.get(page);
402
if (!tab) {
403
throw new Error('Failed to execute function against page');
404
}
405
return tab.safeRunAgainstPage(async () => callback(page));
406
}
407
408
async getSummary(pageId: string, full = false): Promise<string> {
409
const page = await this.getPage(pageId);
410
const tab = this._tabs.get(page);
411
if (!tab) {
412
throw new Error('Failed to get page summary');
413
}
414
return tab.getSummary(full);
415
}
416
417
async replyToDialog(pageId: string, accept: boolean, promptText?: string): Promise<string> {
418
const page = await this.getPage(pageId);
419
const tab = this._tabs.get(page);
420
if (!tab) {
421
throw new Error('Failed to reply to dialog');
422
}
423
await tab.replyToDialog(accept, promptText);
424
return tab.getSummary();
425
}
426
427
async replyToFileChooser(pageId: string, files: string[]): Promise<string> {
428
const page = await this.getPage(pageId);
429
const tab = this._tabs.get(page);
430
if (!tab) {
431
throw new Error('Failed to reply to file chooser');
432
}
433
await tab.replyToFileChooser(files);
434
return tab.getSummary();
435
}
436
437
// --- Initialization ---
438
439
/**
440
* Wire up the manager to a browser and group. Replays any pages that
441
* were tracked before initialization.
442
*/
443
async initialize(browser: Browser, group: IBrowserViewGroup): Promise<void> {
444
this._initStore.clear();
445
446
this._browser = browser;
447
this._group = group;
448
449
this._initStore.add(group);
450
this._initStore.add(group.onDidAddView(e => this.onViewAdded(e.viewId)));
451
this._initStore.add(group.onDidRemoveView(e => this.onViewRemoved(e.viewId)));
452
453
this.scanForNewContexts();
454
455
// Eagerly connect any pages that were tracked before initialization.
456
await Promise.all(
457
[...this._trackedPages].map(viewId => this._addPageToGroup(viewId))
458
);
459
}
460
461
/**
462
* Clear initialized state but preserve tracked pages so the manager
463
* can be re-initialized with a new browser and group.
464
*/
465
reset(): void {
466
this._initStore.clear();
467
this._browser = undefined;
468
this._group = undefined;
469
470
this.stopScanning();
471
this._viewIdToPage.clear();
472
473
for (const { page } of this._viewIdQueue) {
474
page.error(new Error('PlaywrightPageManager reset'));
475
}
476
for (const { viewId } of this._pageQueue) {
477
viewId.error(new Error('PlaywrightPageManager reset'));
478
}
479
this._viewIdQueue = [];
480
this._pageQueue = [];
481
}
482
483
// --- Private: group proxy ---
484
485
private async _addPageToGroup(viewId: string): Promise<void> {
486
if (this._viewIdToPage.has(viewId)) {
487
return;
488
}
489
if (this._viewIdQueue.some(item => item.viewId === viewId)) {
490
return;
491
}
492
493
// Ensure the viewId is queued so we can immediately fetch the promise via getPage().
494
this.onViewAdded(viewId);
495
496
try {
497
await this._group!.addView(viewId);
498
} catch (err) {
499
this.onViewRemoved(viewId);
500
throw err;
501
}
502
}
503
504
private async _removePageFromGroup(viewId: string): Promise<void> {
505
this.onViewRemoved(viewId);
506
await this._group!.removeView(viewId);
507
}
508
509
private _fireTrackedPagesChanged(): void {
510
this._onDidChangeTrackedPages.fire([...this._trackedPages]);
511
}
512
513
// --- Page matching (view ↔ page pairing) ---
514
515
/**
516
* Get the Playwright {@link Page} for a browser view.
517
* If the view is tracked but not yet connected, it is added to the group
518
* automatically. Throws if the view has not been added.
519
*/
520
private async getPage(viewId: string): Promise<Page> {
521
const resolved = this._viewIdToPage.get(viewId);
522
if (resolved) {
523
return resolved;
524
}
525
const queued = this._viewIdQueue.find(item => item.viewId === viewId);
526
if (queued) {
527
return queued.page.p;
528
}
529
530
throw new Error(`Page "${viewId}" not found`);
531
}
532
533
/**
534
* Called when the group fires onDidAddView. Creates a deferred entry in
535
* the view ID queue and attempts to match it with a page.
536
*/
537
private onViewAdded(viewId: string, timeoutMs = 10000): Promise<Page> {
538
const resolved = this._viewIdToPage.get(viewId);
539
if (resolved) {
540
return Promise.resolve(resolved);
541
}
542
const queued = this._viewIdQueue.find(item => item.viewId === viewId);
543
if (queued) {
544
return queued.page.p;
545
}
546
547
const deferred = new DeferredPromise<Page>();
548
const timeout = setTimeout(() => deferred.error(new Error(`Timed out waiting for page`)), timeoutMs);
549
550
deferred.p.finally(() => {
551
clearTimeout(timeout);
552
this._viewIdQueue = this._viewIdQueue.filter(item => item.viewId !== viewId);
553
if (this._viewIdQueue.length === 0) {
554
this.stopScanning();
555
}
556
});
557
558
this._viewIdQueue.push({ viewId, page: deferred });
559
this.tryMatch();
560
this.ensureScanning();
561
562
return deferred.p;
563
}
564
565
private onViewRemoved(viewId: string): void {
566
this._viewIdQueue = this._viewIdQueue.filter(item => item.viewId !== viewId);
567
const page = this._viewIdToPage.get(viewId);
568
if (page) {
569
this._pageToViewId.delete(page);
570
}
571
this._viewIdToPage.delete(viewId);
572
this._trackedPages.delete(viewId);
573
this._fireTrackedPagesChanged();
574
}
575
576
private onPageAdded(page: Page, timeoutMs = 10000): Promise<string> {
577
const resolved = this._pageToViewId.get(page);
578
if (resolved) {
579
return Promise.resolve(resolved);
580
}
581
const queued = this._pageQueue.find(item => item.page === page);
582
if (queued) {
583
return queued.viewId.p;
584
}
585
586
this.onContextAdded(page.context());
587
page.once('close', () => this.onPageRemoved(page));
588
page.setDefaultTimeout(10000);
589
this._tabs.set(page, new PlaywrightTab(page, this.agentNetworkFilterService));
590
591
const deferred = new DeferredPromise<string>();
592
const timeout = setTimeout(() => deferred.error(new Error(`Timed out waiting for browser view`)), timeoutMs);
593
deferred.p.finally(() => {
594
clearTimeout(timeout);
595
this._pageQueue = this._pageQueue.filter(item => item.page !== page);
596
});
597
598
this._pageQueue.push({ page, viewId: deferred });
599
this.tryMatch();
600
601
return deferred.p;
602
}
603
604
private onPageRemoved(page: Page): void {
605
this._pageQueue = this._pageQueue.filter(item => item.page !== page);
606
const viewId = this._pageToViewId.get(page);
607
if (viewId) {
608
this._viewIdToPage.delete(viewId);
609
this._trackedPages.delete(viewId);
610
this._fireTrackedPagesChanged();
611
}
612
this._pageToViewId.delete(page);
613
}
614
615
private onContextAdded(context: BrowserContext): void {
616
if (this._watchedContexts.has(context)) {
617
return;
618
}
619
this._watchedContexts.add(context);
620
621
context.on('page', (page: Page) => this.onPageAdded(page));
622
context.on('close', () => this.onContextRemoved(context));
623
624
for (const page of context.pages()) {
625
this.onPageAdded(page);
626
}
627
}
628
629
private onContextRemoved(context: BrowserContext): void {
630
this._watchedContexts.delete(context);
631
}
632
633
// --- Matching ---
634
635
/**
636
* Pair up queued view IDs with queued pages in FIFO order and resolve
637
* any callers waiting for the matched view IDs.
638
*/
639
private tryMatch(): void {
640
while (this._viewIdQueue.length > 0 && this._pageQueue.length > 0) {
641
const viewIdItem = this._viewIdQueue.shift()!;
642
const pageItem = this._pageQueue.shift()!;
643
644
this._viewIdToPage.set(viewIdItem.viewId, pageItem.page);
645
this._pageToViewId.set(pageItem.page, viewIdItem.viewId);
646
647
viewIdItem.page.complete(pageItem.page);
648
pageItem.viewId.complete(viewIdItem.viewId);
649
650
this.logService.debug(`[PlaywrightPageManager] Matched view ${viewIdItem.viewId} page`);
651
}
652
653
if (this._viewIdQueue.length === 0) {
654
this.stopScanning();
655
}
656
}
657
658
// --- Context scanning ---
659
660
/**
661
* Watch all current {@link BrowserContext BrowserContexts} for new pages.
662
* Also processes any existing pages in newly discovered contexts.
663
*/
664
private scanForNewContexts(): void {
665
if (!this._browser) {
666
return;
667
}
668
for (const context of this._browser.contexts()) {
669
this.onContextAdded(context);
670
}
671
}
672
673
private ensureScanning(): void {
674
if (this._scanTimer === undefined) {
675
this._scanTimer = setInterval(() => this.scanForNewContexts(), 100);
676
}
677
}
678
679
private stopScanning(): void {
680
if (this._scanTimer !== undefined) {
681
clearInterval(this._scanTimer);
682
this._scanTimer = undefined;
683
}
684
}
685
686
override dispose(): void {
687
this.stopScanning();
688
for (const { page } of this._viewIdQueue) {
689
page.error(new Error('PlaywrightPageManager disposed'));
690
}
691
for (const { viewId } of this._pageQueue) {
692
viewId.error(new Error('PlaywrightPageManager disposed'));
693
}
694
this._viewIdQueue = [];
695
this._pageQueue = [];
696
super.dispose();
697
}
698
}
699
700