Path: blob/main/src/vs/platform/browserView/node/playwrightService.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*--------------------------------------------------------------------------------------------*/45import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js';6import { DeferredPromise, disposableTimeout, raceTimeout } from '../../../base/common/async.js';7import { Emitter, Event } from '../../../base/common/event.js';8import { ILogService } from '../../log/common/log.js';9import { IAgentNetworkFilterService } from '../../networkFilter/common/networkFilterService.js';10import { IInvokeFunctionResult, IPlaywrightService } from '../common/playwrightService.js';11import { IBrowserViewGroupRemoteService } from '../node/browserViewGroupRemoteService.js';12import { IBrowserViewGroup } from '../common/browserViewGroup.js';13import { PlaywrightTab, DialogInterruptedError } from './playwrightTab.js';14import { CDPEvent, CDPRequest, CDPResponse } from '../common/cdp/types.js';15import { generateUuid } from '../../../base/common/uuid.js';1617// eslint-disable-next-line local/code-import-patterns18import type { Browser, BrowserContext, Page } from 'playwright-core';1920interface PlaywrightTransport {21send(s: CDPRequest): void;22close(): void; // Note: calling close is expected to issue onclose at some point.23onmessage?: (message: CDPResponse | CDPEvent) => void;24onclose?: (reason?: string) => void;25}2627declare module 'playwright-core' {28interface BrowserType {29_connectOverCDPTransport(transport: PlaywrightTransport): Promise<Browser>;30}31}3233const DEFERRED_RESULT_CLEANUP_MS = 5 * 60_000; // 5 minutes3435/**36* Shared-process implementation of {@link IPlaywrightService}.37*38* Creates a {@link PlaywrightPageManager} eagerly on construction to track39* browser views. The Playwright browser connection is lazily initialised40* only when an operation that requires it is called.41*/42export class PlaywrightService extends Disposable implements IPlaywrightService {43declare readonly _serviceBrand: undefined;4445private readonly _pages: PlaywrightPageManager;46readonly onDidChangeTrackedPages: Event<readonly string[]>;4748private _browser: Browser | undefined;49private _initPromise: Promise<void> | undefined;5051/** In-flight deferred results keyed by their generated ID. */52private readonly _deferredResults = this._register(new DisposableMap<string, {53pageId: string;54promise: Promise<unknown>;55} & IDisposable>());5657constructor(58private readonly windowId: number,59private readonly browserViewGroupRemoteService: IBrowserViewGroupRemoteService,60private readonly logService: ILogService,61agentNetworkFilterService: IAgentNetworkFilterService,62) {63super();64this._pages = this._register(new PlaywrightPageManager(logService, agentNetworkFilterService));65this.onDidChangeTrackedPages = this._pages.onDidChangeTrackedPages;66}6768// --- Page tracking (delegated to manager) ---6970async startTrackingPage(viewId: string): Promise<void> {71return this._pages.startTrackingPage(viewId);72}7374async stopTrackingPage(viewId: string): Promise<void> {75return this._pages.stopTrackingPage(viewId);76}7778async isPageTracked(viewId: string): Promise<boolean> {79return this._pages.isPageTracked(viewId);80}8182async getTrackedPages(): Promise<readonly string[]> {83return this._pages.getTrackedPages();84}8586// --- Playwright operations (lazy init) ---8788/**89* Ensure the Playwright browser connection is initialized and the page90* manager is wired up to the browser view group.91*/92private async initialize(): Promise<void> {93if (this._browser) {94return;95}9697if (this._initPromise) {98return this._initPromise;99}100101this._initPromise = (async () => {102try {103this.logService.debug('[PlaywrightService] Creating browser view group');104const group = await this.browserViewGroupRemoteService.createGroup({ mainWindowId: this.windowId });105106this.logService.debug('[PlaywrightService] Connecting to browser via CDP');107const playwright = await import('playwright-core');108const sub = group.onCDPMessage(msg => transport.onmessage?.(msg));109const transport: PlaywrightTransport = {110close() {111sub.dispose();112this.onclose?.();113},114send(message) {115void group.sendCDPMessage(message);116}117};118const browser = await playwright.chromium._connectOverCDPTransport(transport);119120this.logService.debug('[PlaywrightService] Connected to browser');121122// This can happen if the service was disposed while we were waiting for the connection. In that case, clean up immediately.123if (this._initPromise === undefined) {124browser.close().catch(() => { /* ignore */ });125group.dispose();126throw new Error('PlaywrightService was disposed during initialization');127}128129browser.on('disconnected', () => {130this.logService.debug('[PlaywrightService] Browser disconnected');131if (this._browser === browser) {132this._pages.reset();133this._browser = undefined;134this._initPromise = undefined;135}136});137138await this._pages.initialize(browser, group);139this._browser = browser;140} catch (e) {141this._initPromise = undefined;142throw e;143}144})();145146return this._initPromise;147}148149async openPage(url: string): Promise<{ pageId: string; summary: string }> {150await this.initialize();151const pageId = await this._pages.newPage(url);152const summary = await this._pages.getSummary(pageId);153return { pageId, summary };154}155156async getSummary(pageId: string): Promise<string> {157await this.initialize();158return this._pages.getSummary(pageId, true);159}160161async invokeFunctionRaw<T>(pageId: string, fnDef: string, ...args: unknown[]): Promise<T> {162await this.initialize();163164const vm = await import('vm');165const fn = vm.compileFunction(`return (${fnDef})(page, ...args)`, ['page', 'args'], { parsingContext: vm.createContext() });166167return this._pages.runAgainstPage(pageId, (page) => fn(page, args));168}169170private async invokeFunctionWithDeferral<T>(pageId: string, fnDef: string, args: unknown[], timeoutMs: number): Promise<IInvokeFunctionResult> {171await this.initialize();172173const vm = await import('vm');174const fn = vm.compileFunction(`return (${fnDef})(page, ...args)`, ['page', 'args'], { parsingContext: vm.createContext() });175176return this._runWithDeferral(pageId, (page) => fn(page, args ?? []), timeoutMs);177}178179async invokeFunction(pageId: string, fnDef: string, args: unknown[] = [], timeoutMs?: number): Promise<IInvokeFunctionResult> {180this.logService.info(`[PlaywrightService] Invoking function on view ${pageId}`);181182if (timeoutMs !== undefined) {183return this.invokeFunctionWithDeferral(pageId, fnDef, args, timeoutMs);184}185186let result, error;187try {188result = await this.invokeFunctionRaw(pageId, fnDef, ...args);189} catch (err: unknown) {190error = err instanceof Error ? err.message : String(err);191}192193const summary = await this._pages.getSummary(pageId);194195return { result, error, summary };196}197198async waitForDeferredResult(deferredResultId: string, timeoutMs: number): Promise<IInvokeFunctionResult> {199const entry = this._deferredResults.get(deferredResultId);200if (!entry) {201throw new Error(`No deferred result found with ID "${deferredResultId}". It may have been cleaned up or already consumed.`);202}203204const { pageId, promise } = entry;205// Remove eagerly — _runWithDeferral will re-insert if interrupted again.206this._deferredResults.deleteAndDispose(deferredResultId);207208// The callback ignores the page param since execution is already in-flight.209return this._runWithDeferral(pageId, () => promise, timeoutMs, deferredResultId);210}211212/**213* Run a callback against a page with deferred result support.214*/215private async _runWithDeferral(pageId: string, callback: (page: Page) => Promise<unknown>, timeoutMs: number, existingDeferredId?: string): Promise<IInvokeFunctionResult> {216const effectiveTimeout = timeoutMs;217218// Start execution via safeRunAgainstPage, but capture the raw promise219// independently so it can be deferred if a dialog or timeout interrupts.220const deferred = new DeferredPromise();221const wrappedPromise = this._pages.runAgainstPage(pageId, async (page) => {222const promise = callback(page);223promise.catch(() => { /* prevent unhandled rejection if deferred */ });224deferred.settleWith(promise);225return promise;226});227228let result, error;229let interrupted = false;230231try {232result = await raceTimeout(wrappedPromise, effectiveTimeout, () => { interrupted = true; });233} catch (err: unknown) {234if (err instanceof DialogInterruptedError) {235interrupted = true;236}237error = err instanceof Error ? err.message : String(err);238}239240let deferredResultId: string | undefined;241if (interrupted) {242deferredResultId = existingDeferredId ?? generateUuid();243const cleanup = disposableTimeout(() => this._deferredResults.deleteAndDispose(deferredResultId!), DEFERRED_RESULT_CLEANUP_MS);244this._deferredResults.set(deferredResultId, { pageId, promise: deferred.p, dispose: () => cleanup.dispose() });245246this.logService.info(`[PlaywrightService] Execution interrupted, deferred as ${deferredResultId}`);247}248249const summary = await this._pages.getSummary(pageId);250return { result, error, summary, deferredResultId };251}252253async replyToFileChooser(pageId: string, files: string[]): Promise<{ summary: string }> {254await this.initialize();255const summary = await this._pages.replyToFileChooser(pageId, files);256return { summary };257}258259async replyToDialog(pageId: string, accept: boolean, promptText?: string): Promise<{ summary: string }> {260await this.initialize();261const summary = await this._pages.replyToDialog(pageId, accept, promptText);262return { summary };263}264265override dispose(): void {266if (this._browser) {267this._browser.close().catch(() => { /* ignore */ });268this._browser = undefined;269}270this._initPromise = undefined;271super.dispose();272}273}274275/**276* Manages page tracking and correlates browser view IDs with Playwright277* {@link Page} instances.278*279* Created eagerly by {@link PlaywrightService} and operates in two phases:280*281* 1. **Before initialization** - tracks which pages are added/removed but282* cannot resolve Playwright {@link Page} objects.283* 2. **After {@link initialize}** - proxies add/remove calls to the284* {@link IBrowserViewGroup} and pairs view IDs with Playwright pages285* via FIFO matching of the group's IPC events and Playwright's CDP events.286*287* A periodic scan handles the case where Playwright creates a new288* {@link BrowserContext} for a target whose session was previously unknown.289*/290class PlaywrightPageManager extends Disposable {291292// --- Page tracking ---293294private readonly _trackedPages = new Set<string>();295296private readonly _onDidChangeTrackedPages = this._register(new Emitter<readonly string[]>());297readonly onDidChangeTrackedPages: Event<readonly string[]> = this._onDidChangeTrackedPages.event;298299// --- Page matching ---300301private readonly _viewIdToPage = new Map<string, Page>();302private readonly _pageToViewId = new WeakMap<Page, string>();303private readonly _tabs = new WeakMap<Page, PlaywrightTab>();304305/** View IDs received from the group but not yet matched with a page. */306private _viewIdQueue: Array<{307viewId: string;308page: DeferredPromise<Page>;309}> = [];310311/** Pages received from Playwright but not yet matched with a view ID. */312private _pageQueue: Array<{313page: Page;314viewId: DeferredPromise<string>;315}> = [];316317private readonly _watchedContexts = new WeakSet<BrowserContext>();318private _scanTimer: ReturnType<typeof setInterval> | undefined;319320// --- Initialized state ---321322private readonly _initStore = this._register(new DisposableStore());323private _group: IBrowserViewGroup | undefined;324private _browser: Browser | undefined;325private _openContext: BrowserContext | undefined = undefined;326327constructor(328private readonly logService: ILogService,329private readonly agentNetworkFilterService: IAgentNetworkFilterService,330) {331super();332}333334// --- Public: page tracking ---335336isPageTracked(viewId: string): boolean {337return this._trackedPages.has(viewId);338}339340getTrackedPages(): readonly string[] {341return [...this._trackedPages];342}343344async startTrackingPage(viewId: string): Promise<void> {345if (this._trackedPages.has(viewId)) {346return;347}348349this._trackedPages.add(viewId);350this._fireTrackedPagesChanged();351352if (this._group) {353await this._addPageToGroup(viewId);354}355}356357async stopTrackingPage(viewId: string): Promise<void> {358if (!this._trackedPages.has(viewId)) {359return;360}361362this._trackedPages.delete(viewId);363this._fireTrackedPagesChanged();364365if (this._group) {366await this._removePageFromGroup(viewId);367}368}369370// --- Public: Playwright operations (require initialization) ---371372/**373* Create a new page in the browser and return its associated page ID.374* The page is automatically added to the tracked set.375*/376async newPage(url: string): Promise<string> {377if (!this._browser) {378throw new Error('PlaywrightPageManager has not been initialized');379}380381if (!this._openContext) {382this._openContext = await this._browser.newContext();383this.onContextAdded(this._openContext);384}385386const page = await this._openContext.newPage();387388const viewId = await this.onPageAdded(page);389390this._trackedPages.add(viewId);391this._fireTrackedPagesChanged();392393await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });394395return viewId;396}397398async runAgainstPage<T>(pageId: string, callback: (page: Page) => T | Promise<T>): Promise<T> {399const page = await this.getPage(pageId);400const tab = this._tabs.get(page);401if (!tab) {402throw new Error('Failed to execute function against page');403}404return tab.safeRunAgainstPage(async () => callback(page));405}406407async getSummary(pageId: string, full = false): Promise<string> {408const page = await this.getPage(pageId);409const tab = this._tabs.get(page);410if (!tab) {411throw new Error('Failed to get page summary');412}413return tab.getSummary(full);414}415416async replyToDialog(pageId: string, accept: boolean, promptText?: string): Promise<string> {417const page = await this.getPage(pageId);418const tab = this._tabs.get(page);419if (!tab) {420throw new Error('Failed to reply to dialog');421}422await tab.replyToDialog(accept, promptText);423return tab.getSummary();424}425426async replyToFileChooser(pageId: string, files: string[]): Promise<string> {427const page = await this.getPage(pageId);428const tab = this._tabs.get(page);429if (!tab) {430throw new Error('Failed to reply to file chooser');431}432await tab.replyToFileChooser(files);433return tab.getSummary();434}435436// --- Initialization ---437438/**439* Wire up the manager to a browser and group. Replays any pages that440* were tracked before initialization.441*/442async initialize(browser: Browser, group: IBrowserViewGroup): Promise<void> {443this._initStore.clear();444445this._browser = browser;446this._group = group;447448this._initStore.add(group);449this._initStore.add(group.onDidAddView(e => this.onViewAdded(e.viewId)));450this._initStore.add(group.onDidRemoveView(e => this.onViewRemoved(e.viewId)));451452this.scanForNewContexts();453454// Eagerly connect any pages that were tracked before initialization.455await Promise.all(456[...this._trackedPages].map(viewId => this._addPageToGroup(viewId))457);458}459460/**461* Clear initialized state but preserve tracked pages so the manager462* can be re-initialized with a new browser and group.463*/464reset(): void {465this._initStore.clear();466this._browser = undefined;467this._group = undefined;468469this.stopScanning();470this._viewIdToPage.clear();471472for (const { page } of this._viewIdQueue) {473page.error(new Error('PlaywrightPageManager reset'));474}475for (const { viewId } of this._pageQueue) {476viewId.error(new Error('PlaywrightPageManager reset'));477}478this._viewIdQueue = [];479this._pageQueue = [];480}481482// --- Private: group proxy ---483484private async _addPageToGroup(viewId: string): Promise<void> {485if (this._viewIdToPage.has(viewId)) {486return;487}488if (this._viewIdQueue.some(item => item.viewId === viewId)) {489return;490}491492// Ensure the viewId is queued so we can immediately fetch the promise via getPage().493this.onViewAdded(viewId);494495try {496await this._group!.addView(viewId);497} catch (err) {498this.onViewRemoved(viewId);499throw err;500}501}502503private async _removePageFromGroup(viewId: string): Promise<void> {504this.onViewRemoved(viewId);505await this._group!.removeView(viewId);506}507508private _fireTrackedPagesChanged(): void {509this._onDidChangeTrackedPages.fire([...this._trackedPages]);510}511512// --- Page matching (view ↔ page pairing) ---513514/**515* Get the Playwright {@link Page} for a browser view.516* If the view is tracked but not yet connected, it is added to the group517* automatically. Throws if the view has not been added.518*/519private async getPage(viewId: string): Promise<Page> {520const resolved = this._viewIdToPage.get(viewId);521if (resolved) {522return resolved;523}524const queued = this._viewIdQueue.find(item => item.viewId === viewId);525if (queued) {526return queued.page.p;527}528529throw new Error(`Page "${viewId}" not found`);530}531532/**533* Called when the group fires onDidAddView. Creates a deferred entry in534* the view ID queue and attempts to match it with a page.535*/536private onViewAdded(viewId: string, timeoutMs = 10000): Promise<Page> {537const resolved = this._viewIdToPage.get(viewId);538if (resolved) {539return Promise.resolve(resolved);540}541const queued = this._viewIdQueue.find(item => item.viewId === viewId);542if (queued) {543return queued.page.p;544}545546const deferred = new DeferredPromise<Page>();547const timeout = setTimeout(() => deferred.error(new Error(`Timed out waiting for page`)), timeoutMs);548549deferred.p.finally(() => {550clearTimeout(timeout);551this._viewIdQueue = this._viewIdQueue.filter(item => item.viewId !== viewId);552if (this._viewIdQueue.length === 0) {553this.stopScanning();554}555});556557this._viewIdQueue.push({ viewId, page: deferred });558this.tryMatch();559this.ensureScanning();560561return deferred.p;562}563564private onViewRemoved(viewId: string): void {565this._viewIdQueue = this._viewIdQueue.filter(item => item.viewId !== viewId);566const page = this._viewIdToPage.get(viewId);567if (page) {568this._pageToViewId.delete(page);569}570this._viewIdToPage.delete(viewId);571this._trackedPages.delete(viewId);572this._fireTrackedPagesChanged();573}574575private onPageAdded(page: Page, timeoutMs = 10000): Promise<string> {576const resolved = this._pageToViewId.get(page);577if (resolved) {578return Promise.resolve(resolved);579}580const queued = this._pageQueue.find(item => item.page === page);581if (queued) {582return queued.viewId.p;583}584585this.onContextAdded(page.context());586page.once('close', () => this.onPageRemoved(page));587page.setDefaultTimeout(10000);588this._tabs.set(page, new PlaywrightTab(page, this.agentNetworkFilterService));589590const deferred = new DeferredPromise<string>();591const timeout = setTimeout(() => deferred.error(new Error(`Timed out waiting for browser view`)), timeoutMs);592deferred.p.finally(() => {593clearTimeout(timeout);594this._pageQueue = this._pageQueue.filter(item => item.page !== page);595});596597this._pageQueue.push({ page, viewId: deferred });598this.tryMatch();599600return deferred.p;601}602603private onPageRemoved(page: Page): void {604this._pageQueue = this._pageQueue.filter(item => item.page !== page);605const viewId = this._pageToViewId.get(page);606if (viewId) {607this._viewIdToPage.delete(viewId);608this._trackedPages.delete(viewId);609this._fireTrackedPagesChanged();610}611this._pageToViewId.delete(page);612}613614private onContextAdded(context: BrowserContext): void {615if (this._watchedContexts.has(context)) {616return;617}618this._watchedContexts.add(context);619620context.on('page', (page: Page) => this.onPageAdded(page));621context.on('close', () => this.onContextRemoved(context));622623for (const page of context.pages()) {624this.onPageAdded(page);625}626}627628private onContextRemoved(context: BrowserContext): void {629this._watchedContexts.delete(context);630}631632// --- Matching ---633634/**635* Pair up queued view IDs with queued pages in FIFO order and resolve636* any callers waiting for the matched view IDs.637*/638private tryMatch(): void {639while (this._viewIdQueue.length > 0 && this._pageQueue.length > 0) {640const viewIdItem = this._viewIdQueue.shift()!;641const pageItem = this._pageQueue.shift()!;642643this._viewIdToPage.set(viewIdItem.viewId, pageItem.page);644this._pageToViewId.set(pageItem.page, viewIdItem.viewId);645646viewIdItem.page.complete(pageItem.page);647pageItem.viewId.complete(viewIdItem.viewId);648649this.logService.debug(`[PlaywrightPageManager] Matched view ${viewIdItem.viewId} → page`);650}651652if (this._viewIdQueue.length === 0) {653this.stopScanning();654}655}656657// --- Context scanning ---658659/**660* Watch all current {@link BrowserContext BrowserContexts} for new pages.661* Also processes any existing pages in newly discovered contexts.662*/663private scanForNewContexts(): void {664if (!this._browser) {665return;666}667for (const context of this._browser.contexts()) {668this.onContextAdded(context);669}670}671672private ensureScanning(): void {673if (this._scanTimer === undefined) {674this._scanTimer = setInterval(() => this.scanForNewContexts(), 100);675}676}677678private stopScanning(): void {679if (this._scanTimer !== undefined) {680clearInterval(this._scanTimer);681this._scanTimer = undefined;682}683}684685override dispose(): void {686this.stopScanning();687for (const { page } of this._viewIdQueue) {688page.error(new Error('PlaywrightPageManager disposed'));689}690for (const { viewId } of this._pageQueue) {691viewId.error(new Error('PlaywrightPageManager disposed'));692}693this._viewIdQueue = [];694this._pageQueue = [];695super.dispose();696}697}698699700