From 828c53045686704148e32e4ffa674799dad9a963 Mon Sep 17 00:00:00 2001 From: Kyle Cutler Date: Fri, 20 Feb 2026 15:53:43 -0800 Subject: [PATCH 1/5] Browser: preparation for tools --- package-lock.json | 54 ++- package.json | 2 +- .../platform/browserView/common/cdp/proxy.ts | 14 +- .../browserView/common/playwrightService.ts | 86 ++++- .../electron-main/browserViewGroup.ts | 4 + .../browserView/node/playwrightService.ts | 353 +++++++++++++++--- .../browserView/node/playwrightTab.ts | 192 ++++++++++ .../contrib/browserView/common/browserView.ts | 32 +- 8 files changed, 665 insertions(+), 72 deletions(-) create mode 100644 src/vs/platform/browserView/node/playwrightTab.ts diff --git a/package-lock.json b/package-lock.json index 8524540f7d49dc..ebb7c60e934719 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,7 +50,7 @@ "native-keymap": "^3.3.5", "node-pty": "^1.2.0-beta.10", "open": "^10.1.2", - "playwright-core": "^1.58.2", + "playwright-core": "1.59.0-alpha-2026-02-20", "tas-client": "0.3.1", "undici": "^7.18.2", "v8-inspect-profiler": "^0.1.1", @@ -2074,6 +2074,38 @@ "node": ">=18" } }, + "node_modules/@playwright/test/node_modules/playwright": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", + "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.56.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/@playwright/test/node_modules/playwright-core": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", + "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@pondwader/socks5-server": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/@pondwader/socks5-server/-/socks5-server-1.0.10.tgz", @@ -13953,13 +13985,13 @@ } }, "node_modules/playwright": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", - "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.56.1" + "playwright-core": "1.58.2" }, "bin": { "playwright": "cli.js" @@ -13972,9 +14004,9 @@ } }, "node_modules/playwright-core": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", - "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "version": "1.59.0-alpha-2026-02-20", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.0-alpha-2026-02-20.tgz", + "integrity": "sha512-BK7oUBgMSbxfkQ579s270t0EkEyT2L2DA7qfMV4kaHanQOO0UK4mfyVLpWQsa+vUr/l7LxJGWsKlWcXD2QU9NQ==", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -13984,9 +14016,9 @@ } }, "node_modules/playwright/node_modules/playwright-core": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", - "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index f73c3d4705795a..f7eda48a971eb0 100644 --- a/package.json +++ b/package.json @@ -116,7 +116,7 @@ "native-keymap": "^3.3.5", "node-pty": "^1.2.0-beta.10", "open": "^10.1.2", - "playwright-core": "^1.58.2", + "playwright-core": "1.59.0-alpha-2026-02-20", "tas-client": "0.3.1", "undici": "^7.18.2", "v8-inspect-profiler": "^0.1.1", diff --git a/src/vs/platform/browserView/common/cdp/proxy.ts b/src/vs/platform/browserView/common/cdp/proxy.ts index e11b7b26389860..85dc5f6d52d845 100644 --- a/src/vs/platform/browserView/common/cdp/proxy.ts +++ b/src/vs/platform/browserView/common/cdp/proxy.ts @@ -69,7 +69,19 @@ export class CDPBrowserProxy extends Disposable implements ICDPConnection { await this.attachToTarget(targetInfo.targetId, true); } }); - this._targets.onDidUnregisterTarget(async ({ targetInfo }) => { + this._targets.onDidUnregisterTarget(({ targetInfo }) => { + // Close any sessions attached to the destroyed target. Snapshot first + // to avoid mutating _sessions while iterating (onClose fires synchronously). + const toDispose: ICDPConnection[] = []; + for (const [, connection] of this._sessions) { + if (this._sessionTargetIds.get(connection) === targetInfo.targetId) { + toDispose.push(connection); + } + } + for (const connection of toDispose) { + connection.dispose(); + } + if (this._discover) { this.sendBrowserEvent('Target.targetDestroyed', { targetId: targetInfo.targetId }); } diff --git a/src/vs/platform/browserView/common/playwrightService.ts b/src/vs/platform/browserView/common/playwrightService.ts index 75240497f8832f..14e2c3d589a5bc 100644 --- a/src/vs/platform/browserView/common/playwrightService.ts +++ b/src/vs/platform/browserView/common/playwrightService.ts @@ -3,16 +3,98 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Event } from '../../../base/common/event.js'; +import { VSBuffer } from '../../../base/common/buffer.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; export const IPlaywrightService = createDecorator('playwrightService'); /** * A service for using Playwright to connect to and automate the integrated browser. + * + * Pages must be explicitly tracked via {@link startTrackingPage} (or implicitly via + * {@link openPage}) before they can be interacted with. */ export interface IPlaywrightService { readonly _serviceBrand: undefined; - // TODO@kycutler: define a more specific API. - initialize(): Promise; + /** + * Fires when the set of tracked pages changes. + * The event value is the full list of currently tracked view IDs. + */ + readonly onDidChangeTrackedPages: Event; + + /** + * Start tracking an existing browser view so that agent + * tools can interact with it. + * @param viewId The browser view identifier. + */ + startTrackingPage(viewId: string): Promise; + + /** + * Stop tracking a browser view. + * @param viewId The browser view identifier. + */ + stopTrackingPage(viewId: string): Promise; + + /** + * Whether the given page is currently tracked by the service. + */ + isPageTracked(viewId: string): Promise; + + /** + * Get the list of currently tracked page IDs. + */ + getTrackedPages(): Promise; + + /** + * Opens a new page in the browser and returns its associated view ID. + * The page is automatically added to the tracked pages. + * @param url The URL to open in the new page. + * @returns An object containing the new page's view ID and a summary of its initial state. + */ + openPage(url: string): Promise<{ pageId: string; summary: string }>; + + /** + * Gets a summary of the page's current state, including its DOM and visual representation. + * @param pageId The browser view ID identifying the page to read. + * @returns The summary of the page's current state. + */ + getSummary(pageId: string): Promise; + + /** + * Run a function with access to a Playwright page. + * The first function argument is always the Playwright `page` object, and additional arguments can be passed after. + * @param pageId The browser view ID identifying the page to operate on. + * @param fnDef The function code to execute. Should contain the function definition but not its invocation, e.g. `async (page, arg1, arg2) => { ... }`. + * @param args Additional arguments to pass to the function after the `page` object. + * @returns The result of the function execution, including a page summary. + */ + invokeFunction(pageId: string, fnDef: string, ...args: unknown[]): Promise<{ result: TResult; summary: string }>; + + /** + * Takes a screenshot of the given page and returns it as a VSBuffer. + * @param pageId The browser view ID identifying the page to capture. + * @param selector Optional Playwright selector to capture a specific element instead of the full page. + * @param fullPage Whether to capture the full scrollable page instead of just the viewport. + * @returns The screenshot image data. + */ + captureScreenshot(pageId: string, selector?: string, fullPage?: boolean): Promise; + + /** + * Responds to a file chooser dialog on the given page. + * @param pageId The browser view ID identifying the page. + * @param files The list of files to select in the file chooser. Empty to dismiss the dialog without selecting files. + * @returns An object with the page summary afterwards. + */ + replyToFileChooser(pageId: string, files: string[]): Promise<{ summary: string }>; + + /** + * Responds to a dialog (alert, confirm, prompt) on the given page. + * @param pageId The browser view ID identifying the page. + * @param accept Whether to accept or dismiss the dialog. + * @param promptText Optional text to enter into a prompt dialog. + * @returns An object with the page summary afterwards. + */ + replyToDialog(pageId: string, accept: boolean, promptText?: string): Promise<{ summary: string }>; } diff --git a/src/vs/platform/browserView/electron-main/browserViewGroup.ts b/src/vs/platform/browserView/electron-main/browserViewGroup.ts index efd4d01abf3fba..d7d59c27018894 100644 --- a/src/vs/platform/browserView/electron-main/browserViewGroup.ts +++ b/src/vs/platform/browserView/electron-main/browserViewGroup.ts @@ -87,6 +87,10 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I async removeView(viewId: string): Promise { const view = this.views.get(viewId); if (view && this.views.delete(viewId)) { + // If no remaining views belong to the view's context, and we don't own the context, remove it from known contexts + if (!this.ownedContextIds.has(view.session.id) && ![...this.views.values()].some(v => v.session.id === view.session.id)) { + this.knownContextIds.delete(view.session.id); + } this._onDidRemoveView.fire({ viewId: view.id }); this._onTargetDestroyed.fire(view); } diff --git a/src/vs/platform/browserView/node/playwrightService.ts b/src/vs/platform/browserView/node/playwrightService.ts index f19ee20bd94ea4..60c3eea9ace932 100644 --- a/src/vs/platform/browserView/node/playwrightService.ts +++ b/src/vs/platform/browserView/node/playwrightService.ts @@ -3,24 +3,33 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable } from '../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; import { DeferredPromise } from '../../../base/common/async.js'; +import { Emitter, Event } from '../../../base/common/event.js'; import { ILogService } from '../../log/common/log.js'; import { IPlaywrightService } from '../common/playwrightService.js'; import { IBrowserViewGroupRemoteService } from '../node/browserViewGroupRemoteService.js'; import { IBrowserViewGroup } from '../common/browserViewGroup.js'; +import { VSBuffer } from '../../../base/common/buffer.js'; +import { PlaywrightTab } from './playwrightTab.js'; // eslint-disable-next-line local/code-import-patterns import type { Browser, BrowserContext, Page } from 'playwright-core'; /** * Shared-process implementation of {@link IPlaywrightService}. + * + * Creates a {@link PlaywrightPageManager} eagerly on construction to track + * browser views. The Playwright browser connection is lazily initialised + * only when an operation that requires it is called. */ export class PlaywrightService extends Disposable implements IPlaywrightService { declare readonly _serviceBrand: undefined; + private readonly _pages: PlaywrightPageManager; + readonly onDidChangeTrackedPages: Event; + private _browser: Browser | undefined; - private _pages: PlaywrightPageManager | undefined; private _initPromise: Promise | undefined; constructor( @@ -28,13 +37,36 @@ export class PlaywrightService extends Disposable implements IPlaywrightService @ILogService private readonly logService: ILogService, ) { super(); + this._pages = this._register(new PlaywrightPageManager(logService)); + this.onDidChangeTrackedPages = this._pages.onDidChangeTrackedPages; + } + + // --- Page tracking (delegated to manager) --- + + async startTrackingPage(viewId: string): Promise { + return this._pages.startTrackingPage(viewId); + } + + async stopTrackingPage(viewId: string): Promise { + return this._pages.stopTrackingPage(viewId); + } + + async isPageTracked(viewId: string): Promise { + return this._pages.isPageTracked(viewId); } + async getTrackedPages(): Promise { + return this._pages.getTrackedPages(); + } + + // --- Playwright operations (lazy init) --- + /** - * Ensure the Playwright browser connection and page map are initialized. + * Ensure the Playwright browser connection is initialized and the page + * manager is wired up to the browser view group. */ - async initialize(): Promise { - if (this._pages) { + private async initialize(): Promise { + if (this._browser) { return; } @@ -45,7 +77,7 @@ export class PlaywrightService extends Disposable implements IPlaywrightService this._initPromise = (async () => { try { this.logService.debug('[PlaywrightService] Creating browser view group'); - const group = this._register(await this.browserViewGroupRemoteService.createGroup()); + const group = await this.browserViewGroupRemoteService.createGroup(); this.logService.debug('[PlaywrightService] Connecting to browser via CDP'); const playwright = await import('playwright-core'); @@ -60,22 +92,17 @@ export class PlaywrightService extends Disposable implements IPlaywrightService throw new Error('PlaywrightService was disposed during initialization'); } - const pageManager = this._register(new PlaywrightPageManager(group, browser, this.logService)); - browser.on('disconnected', () => { this.logService.debug('[PlaywrightService] Browser disconnected'); if (this._browser === browser) { - group.dispose(); - pageManager.dispose(); - + this._pages.reset(); this._browser = undefined; - this._pages = undefined; this._initPromise = undefined; } }); + await this._pages.initialize(browser, group); this._browser = browser; - this._pages = pageManager; } catch (e) { this._initPromise = undefined; throw e; @@ -85,6 +112,72 @@ export class PlaywrightService extends Disposable implements IPlaywrightService return this._initPromise; } + async openPage(url: string): Promise<{ pageId: string; summary: string }> { + await this.initialize(); + const { page, viewId } = await this._pages.newPage(); + await page.goto(url, { waitUntil: 'domcontentloaded' }); + const summary = await this._pages.getSummary(page); + return { pageId: viewId, summary }; + } + + async getSummary(pageId: string): Promise { + await this.initialize(); + const page = await this._pages.getPage(pageId); + return this._pages.getSummary(page, true); + } + + async invokeFunction(pageId: string, fnDef: string, ...args: TArgs): Promise<{ result: TReturn; summary: string }> { + this.logService.info(`[PlaywrightService] Invoking function on view ${pageId}`); + + try { + await this.initialize(); + const page = await this._pages.getPage(pageId); + + const vm = await import('vm'); + const fn = vm.compileFunction(`return (${fnDef})(page, ...args)`, ['page', 'args'], { parsingContext: vm.createContext() }); + + let result; + try { + result = await this._pages.runAndWaitForCompletion(page, () => fn(page, args)); + } catch (err: unknown) { + result = err instanceof Error ? err.message : String(err); + } + + const summary = await this._pages.getSummary(page); + return { result, summary }; + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : String(err); + this.logService.error('[PlaywrightService] Script execution failed:', errorMessage); + throw err; + } + } + + async captureScreenshot(pageId: string, selector?: string, fullPage?: boolean): Promise { + await this.initialize(); + const page = await this._pages.getPage(pageId); + if (selector) { + const element = page.locator(selector); + const screenshotBuffer = await element.screenshot({ type: 'jpeg', quality: 80 }); + return VSBuffer.wrap(screenshotBuffer); + } + const screenshotBuffer = await page.screenshot({ type: 'jpeg', quality: 80, fullPage: fullPage ?? false }); + return VSBuffer.wrap(screenshotBuffer); + } + + async replyToFileChooser(pageId: string, files: string[]): Promise<{ summary: string }> { + await this.initialize(); + const page = await this._pages.getPage(pageId); + const summary = await this._pages.replyToFileChooser(page, files); + return { summary }; + } + + async replyToDialog(pageId: string, accept: boolean, promptText?: string): Promise<{ summary: string }> { + await this.initialize(); + const page = await this._pages.getPage(pageId); + const summary = await this._pages.replyToDialog(page, accept, promptText); + return { summary }; + } + override dispose(): void { if (this._browser) { this._browser.close().catch(() => { /* ignore */ }); @@ -96,25 +189,34 @@ export class PlaywrightService extends Disposable implements IPlaywrightService } /** - * Correlates browser view IDs with Playwright {@link Page} instances. + * Manages page tracking and correlates browser view IDs with Playwright + * {@link Page} instances. * - * When a browser view is added to a group, two asynchronous events follow - * through independent channels: + * Created eagerly by {@link PlaywrightService} and operates in two phases: * - * 1. The group fires {@link IBrowserViewGroup.onDidAddView} (via IPC). - * 2. Playwright receives a CDP `Target.targetCreated` event (via WebSocket) - * and fires a `page` event on the matching {@link BrowserContext}. - * - * This class pairs the two event streams by FIFO ordering: the first view-ID - * received is matched with the first page event received. + * 1. **Before initialization** - tracks which pages are added/removed but + * cannot resolve Playwright {@link Page} objects. + * 2. **After {@link initialize}** - proxies add/remove calls to the + * {@link IBrowserViewGroup} and pairs view IDs with Playwright pages + * via FIFO matching of the group's IPC events and Playwright's CDP events. * * A periodic scan handles the case where Playwright creates a new * {@link BrowserContext} for a target whose session was previously unknown. */ class PlaywrightPageManager extends Disposable { + // --- Page tracking --- + + private readonly _trackedPages = new Set(); + + private readonly _onDidChangeTrackedPages = this._register(new Emitter()); + readonly onDidChangeTrackedPages: Event = this._onDidChangeTrackedPages.event; + + // --- Page matching --- + private readonly _viewIdToPage = new Map(); private readonly _pageToViewId = new WeakMap(); + private readonly _tabs = new WeakMap(); /** View IDs received from the group but not yet matched with a page. */ private _viewIdQueue: Array<{ @@ -131,32 +233,180 @@ class PlaywrightPageManager extends Disposable { private readonly _watchedContexts = new WeakSet(); private _scanTimer: ReturnType | undefined; + // --- Initialized state --- + + private readonly _initStore = this._register(new DisposableStore()); + private _group: IBrowserViewGroup | undefined; + private _browser: Browser | undefined; + constructor( - private readonly _group: IBrowserViewGroup, - private readonly _browser: Browser, private readonly logService: ILogService, ) { super(); + } - this._register(_group.onDidAddView(e => this.onViewAdded(e.viewId))); - this._register(_group.onDidRemoveView(e => this.onViewRemoved(e.viewId))); - this.scanForNewContexts(); + // --- Public: page tracking --- + + isPageTracked(viewId: string): boolean { + return this._trackedPages.has(viewId); + } + + getTrackedPages(): readonly string[] { + return [...this._trackedPages]; + } + + async startTrackingPage(viewId: string): Promise { + if (this._trackedPages.has(viewId)) { + return; + } + + this._trackedPages.add(viewId); + this._fireTrackedPagesChanged(); + + if (this._group) { + await this._addPageToGroup(viewId); + } } + async stopTrackingPage(viewId: string): Promise { + if (!this._trackedPages.has(viewId)) { + return; + } + + this._trackedPages.delete(viewId); + this._fireTrackedPagesChanged(); + + if (this._group) { + await this._removePageFromGroup(viewId); + } + } + + // --- Public: Playwright operations (require initialization) --- + /** * Create a new page in the browser and return its associated page and view ID. + * The page is automatically added to the tracked set. */ async newPage(): Promise<{ viewId: string; page: Page }> { + if (!this._browser) { + throw new Error('PlaywrightPageManager has not been initialized'); + } + const page = await this._browser.newPage(); const viewId = await this.onPageAdded(page); + this._trackedPages.add(viewId); + this._fireTrackedPagesChanged(); + return { viewId, page }; } /** - * Explicitly add an existing browser view to the CDP group. + * Get the Playwright {@link Page} for a browser view. + * If the view is tracked but not yet connected, it is added to the group + * automatically. Throws if the view has not been added. */ - async addPage(viewId: string): Promise { + async getPage(viewId: string): Promise { + const resolved = this._viewIdToPage.get(viewId); + if (resolved) { + return resolved; + } + const queued = this._viewIdQueue.find(item => item.viewId === viewId); + if (queued) { + return queued.page.p; + } + + if (this._trackedPages.has(viewId) && this._group) { + await this._addPageToGroup(viewId); + return this.getPage(viewId); + } + + throw new Error(`Page "${viewId}" has not been added to the Playwright service`); + } + + async runAndWaitForCompletion(page: Page, callback: () => T | Promise): Promise { + const tab = this._tabs.get(page); + if (!tab) { + throw new Error('Failed to execute function against page'); + } + return tab.runAndWaitForCompletion(async () => callback()); + } + + getSummary(page: Page, full = false): Promise { + const tab = this._tabs.get(page); + if (!tab) { + throw new Error('Failed to get page summary'); + } + return tab.getSummary(full); + } + + async replyToDialog(page: Page, accept: boolean, promptText?: string): Promise { + const tab = this._tabs.get(page); + if (!tab) { + throw new Error('Failed to reply to dialog'); + } + await tab.replyToDialog(accept, promptText); + return tab.getSummary(); + } + + async replyToFileChooser(page: Page, files: string[]): Promise { + const tab = this._tabs.get(page); + if (!tab) { + throw new Error('Failed to reply to file chooser'); + } + await tab.replyToFileChooser(files); + return tab.getSummary(); + } + + // --- Initialization --- + + /** + * Wire up the manager to a browser and group. Replays any pages that + * were tracked before initialization. + */ + async initialize(browser: Browser, group: IBrowserViewGroup): Promise { + this._initStore.clear(); + + this._browser = browser; + this._group = group; + + this._initStore.add(group); + this._initStore.add(group.onDidAddView(e => this.onViewAdded(e.viewId))); + this._initStore.add(group.onDidRemoveView(e => this.onViewRemoved(e.viewId))); + + this.scanForNewContexts(); + + // Eagerly connect any pages that were tracked before initialization. + await Promise.all( + [...this._trackedPages].map(viewId => this._addPageToGroup(viewId)) + ); + } + + /** + * Clear initialized state but preserve tracked pages so the manager + * can be re-initialized with a new browser and group. + */ + reset(): void { + this._initStore.clear(); + this._browser = undefined; + this._group = undefined; + + this.stopScanning(); + this._viewIdToPage.clear(); + + for (const { page } of this._viewIdQueue) { + page.error(new Error('PlaywrightPageManager reset')); + } + for (const { viewId } of this._pageQueue) { + viewId.error(new Error('PlaywrightPageManager reset')); + } + this._viewIdQueue = []; + this._pageQueue = []; + } + + // --- Private: group proxy --- + + private async _addPageToGroup(viewId: string): Promise { if (this._viewIdToPage.has(viewId)) { return; } @@ -164,48 +414,34 @@ class PlaywrightPageManager extends Disposable { return; } - // ensure the viewId is queued so we can immediately fetch the promise via getPage(). + // Ensure the viewId is queued so we can immediately fetch the promise via getPage(). this.onViewAdded(viewId); try { - await this._group.addView(viewId); + await this._group!.addView(viewId); } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err); - this.logService.error('[PlaywrightPageMap] Failed to add view:', errorMessage); + this.logService.error('[PlaywrightPageManager] Failed to add view:', errorMessage); this.onViewRemoved(viewId); } } - /** - * Remove a browser view from the CDP group. - */ - async removePage(viewId: string): Promise { + private async _removePageFromGroup(viewId: string): Promise { this.onViewRemoved(viewId); try { - await this._group.removeView(viewId); + await this._group!.removeView(viewId); } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err); - this.logService.error('[PlaywrightPageMap] Failed to remove view:', errorMessage); + this.logService.error('[PlaywrightPageManager] Failed to remove view:', errorMessage); } } - /** - * Get the Playwright {@link Page} for a browser view that has already been added. - * Throws if the view has not been added. - */ - async getPage(viewId: string): Promise { - const resolved = this._viewIdToPage.get(viewId); - if (resolved) { - return resolved; - } - const queued = this._viewIdQueue.find(item => item.viewId === viewId); - if (queued) { - return queued.page.p; - } - - throw new Error(`Page "${viewId}" has not been added to the Playwright service`); + private _fireTrackedPagesChanged(): void { + this._onDidChangeTrackedPages.fire([...this._trackedPages]); } + // --- Page matching (view ↔ page pairing) --- + /** * Called when the group fires onDidAddView. Creates a deferred entry in * the view ID queue and attempts to match it with a page. @@ -259,6 +495,8 @@ class PlaywrightPageManager extends Disposable { this.onContextAdded(page.context()); page.once('close', () => this.onPageRemoved(page)); + page.setDefaultTimeout(10000); + this._tabs.set(page, new PlaywrightTab(page)); const deferred = new DeferredPromise(); const timeout = setTimeout(() => deferred.error(new Error(`Timed out waiting for browser view`)), timeoutMs); @@ -317,7 +555,7 @@ class PlaywrightPageManager extends Disposable { viewIdItem.page.complete(pageItem.page); pageItem.viewId.complete(viewIdItem.viewId); - this.logService.debug(`[PlaywrightPageMap] Matched view ${viewIdItem.viewId} → page`); + this.logService.debug(`[PlaywrightPageManager] Matched view ${viewIdItem.viewId} → page`); } if (this._viewIdQueue.length === 0) { @@ -332,6 +570,9 @@ class PlaywrightPageManager extends Disposable { * Also processes any existing pages in newly discovered contexts. */ private scanForNewContexts(): void { + if (!this._browser) { + return; + } for (const context of this._browser.contexts()) { this.onContextAdded(context); } @@ -353,10 +594,10 @@ class PlaywrightPageManager extends Disposable { override dispose(): void { this.stopScanning(); for (const { page } of this._viewIdQueue) { - page.error(new Error('PlaywrightPageMap disposed')); + page.error(new Error('PlaywrightPageManager disposed')); } for (const { viewId } of this._pageQueue) { - viewId.error(new Error('PlaywrightPageMap disposed')); + viewId.error(new Error('PlaywrightPageManager disposed')); } this._viewIdQueue = []; this._pageQueue = []; diff --git a/src/vs/platform/browserView/node/playwrightTab.ts b/src/vs/platform/browserView/node/playwrightTab.ts new file mode 100644 index 00000000000000..4cbe413b63b556 --- /dev/null +++ b/src/vs/platform/browserView/node/playwrightTab.ts @@ -0,0 +1,192 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// eslint-disable-next-line local/code-import-patterns +import type * as playwright from 'playwright-core'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { createCancelablePromise, raceCancellablePromises } from '../../../base/common/async.js'; + +declare module 'playwright-core' { + interface Page { + // A hidden Playwright method that returns an AI-friendly snapshot of the page. + _snapshotForAI(options?: { track?: string }): Promise<{ full: string; incremental?: string }>; + } +} + +/** + * Wrapper around a Playwright page that tracks additional state like active dialogs and recent console messages, + * and can produce a summary of the page's current state for use in tools. + * + * Loosely based on https://github.com/microsoft/playwright/blob/main/packages/playwright/src/mcp/browser/tab.ts. + */ +export class PlaywrightTab { + private _onDialogStateChanged = new Emitter(); + + private _dialog: playwright.Dialog | undefined; + private _fileChooser: playwright.FileChooser | undefined; + private _logs: { type: string; time: number; description: string }[] = []; + + private _initialized: Promise; + + constructor(readonly page: playwright.Page) { + page.on('console', event => this._handleConsoleMessage(event)) + .on('pageerror', error => this._handlePageError(error)) + .on('requestfailed', request => this._handleRequestFailed(request)) + .on('filechooser', chooser => this._handleFileChooser(chooser)) + .on('dialog', dialog => this._handleDialog(dialog)) + .on('download', download => this._handleDownload(download)); + + this._initialized = this._initialize(); + } + + private async _initialize() { + const messages = await this.page.consoleMessages().catch(() => []); + for (const message of messages) { this._handleConsoleMessage(message); } + const errors = await this.page.pageErrors().catch(() => []); + for (const error of errors) { this._handlePageError(error); } + } + + private _handleDialog(dialog: playwright.Dialog) { + this._dialog = dialog; + // 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. + this.page.waitForFunction(() => true, undefined, { timeout: 0 }).then(() => { + if (this._dialog === dialog) { + this._dialog = undefined; + this._onDialogStateChanged.fire(); + } + }); + this._onDialogStateChanged.fire(); + } + + async replyToDialog(accept?: boolean, promptText?: string) { + if (!this._dialog) { + throw new Error('No active dialog to respond to'); + } + const dialog = this._dialog; + this._dialog = undefined; + this._onDialogStateChanged.fire(); + await this.runAndWaitForCompletion(async () => { + if (accept) { + await dialog.accept(promptText); + } else { + await dialog.dismiss(); + } + }); + } + + private _handleFileChooser(chooser: playwright.FileChooser) { + this._fileChooser = chooser; + } + + async replyToFileChooser(files: string[]) { + if (!this._fileChooser) { + throw new Error('No active file chooser to respond to'); + } + const chooser = this._fileChooser; + this._fileChooser = undefined; + await this.runAndWaitForCompletion(() => chooser.setFiles(files)); + } + + private async _handleDownload(download: playwright.Download) { + this._logs.push({ type: 'download', time: Date.now(), description: `${download.suggestedFilename()}` }); + } + + private _handleRequestFailed(request: playwright.Request) { + const timing = request.timing(); + this._logs.push({ type: 'requestFailed', time: timing.responseEnd + timing.startTime, description: `${request.method()} request to ${request.url()} failed: "${request.failure()?.errorText}"` }); + } + + private _handleConsoleMessage(message: playwright.ConsoleMessage) { + if (message.type() === 'error' || message.type() === 'warning') { + this._logs.push({ type: 'console', time: message.timestamp(), description: `[${message.type()}] ${message.text()}` }); + } + } + + private _handlePageError(error: Error) { + this._logs.push({ type: 'pageError', time: Date.now(), description: error.stack ?? error.message }); + } + + async runAndWaitForCompletion(callback: (token: CancellationToken) => Promise): Promise { + // Dialogs pause the page. So we run the callback and wait for either its completion or a dialog to open + if (this._dialog) { + throw new Error(`Cannot perform action while a dialog is open`); + } + + let completed = false; + let result: T | void; + const dialog = Event.toPromise(this._onDialogStateChanged.event); + const action = createCancelablePromise(async (token) => { + result = await runAndWaitForCompletion(this, callback, token); + completed = true; + }); + + return raceCancellablePromises([dialog, action]).then(() => { + if (!completed) { + // A dialog was opened before the action completed. Note we don't cancel the action, just ignore its result. + throw new Error('Action was interrupted by a dialog'); + } + return result!; + }); + } + + async getSummary(full = false): Promise { + await this._initialized; + + // Important: anything run against the page must be wrapped with runAndWaitForCompletion in case dialogs are opened. + const snapshotFromPage = await this.runAndWaitForCompletion(() => this.page._snapshotForAI({ track: 'response' })).catch(() => undefined); + const title = await this.runAndWaitForCompletion(() => this.page.title()).catch(() => ''); + + const logs = this._logs; + this._logs = []; + + const snapshot = (full ? snapshotFromPage?.full : snapshotFromPage?.incremental ?? snapshotFromPage?.full)?.trim() ?? ''; + + return [ + ...(title ? [`Page Title: ${title}`] : []), + `URL: ${this.page.url()}`, + ...(this._dialog ? [`Active ${this._dialog.type()} dialog: "${this._dialog.message()}"`] : []), + ...(this._fileChooser ? [`Active file chooser dialog`] : []), + ...(logs.length > 0 ? [ + `Recent events:`, + ...logs.map(log => `- [${new Date(log.time).toISOString()}] (${log.type}) ${log.description}`) + ] : []), + ...(snapshot ? ['Snapshot:', snapshot] : []) + ].join('\n'); + } +} + +async function runAndWaitForCompletion(tab: PlaywrightTab, callback: (token: CancellationToken) => Promise, token = CancellationToken.None): Promise { + const requests: playwright.Request[] = []; + + const requestListener = (request: playwright.Request) => requests.push(request); + const disposeListeners = () => { + tab.page.off('request', requestListener); + }; + tab.page.on('request', requestListener); + + let result: T; + try { + result = await callback(token); + } finally { + disposeListeners(); + } + + const requestedNavigation = requests.some(request => request.isNavigationRequest()); + if (requestedNavigation) { + await tab.page.mainFrame().waitForLoadState('load', { timeout: 10000 }).catch(() => { }); + return result; + } + + const promises: Promise[] = []; + for (const request of requests) { + if (['document', 'stylesheet', 'script', 'xhr', 'fetch'].includes(request.resourceType())) { promises.push(request.response().then(r => r?.finished()).catch(() => { })); } + else { promises.push(request.response().catch(() => { })); } + } + const timeout = new Promise(resolve => setTimeout(resolve, 5000)); + await Promise.race([Promise.all(promises), timeout]); + + return result; +} diff --git a/src/vs/workbench/contrib/browserView/common/browserView.ts b/src/vs/workbench/contrib/browserView/common/browserView.ts index 732fa1974e481f..8615e08cc6717e 100644 --- a/src/vs/workbench/contrib/browserView/common/browserView.ts +++ b/src/vs/workbench/contrib/browserView/common/browserView.ts @@ -7,6 +7,7 @@ import { createDecorator } from '../../../../platform/instantiation/common/insta import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; import { VSBuffer } from '../../../../base/common/buffer.js'; +import { IPlaywrightService } from '../../../../platform/browserView/common/playwrightService.js'; import { IBrowserViewBounds, IBrowserViewNavigationEvent, @@ -90,7 +91,9 @@ export interface IBrowserViewModel extends IDisposable { readonly error: IBrowserViewLoadError | undefined; readonly storageScope: BrowserViewStorageScope; + readonly sharedWithAgent: boolean; + readonly onDidChangeSharedWithAgent: Event; readonly onDidNavigate: Event; readonly onDidChangeLoadingState: Event; readonly onDidChangeFocus: Event; @@ -120,6 +123,7 @@ export interface IBrowserViewModel extends IDisposable { stopFindInPage(keepSelection?: boolean): Promise; getSelectedText(): Promise; clearStorage(): Promise; + setSharedWithAgent(shared: boolean): Promise; } export class BrowserViewModel extends Disposable implements IBrowserViewModel { @@ -135,6 +139,10 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { private _canGoForward: boolean = false; private _error: IBrowserViewLoadError | undefined = undefined; private _storageScope: BrowserViewStorageScope = BrowserViewStorageScope.Ephemeral; + private _sharedWithAgent: boolean = false; + + private readonly _onDidChangeSharedWithAgent = this._register(new Emitter()); + readonly onDidChangeSharedWithAgent: Event = this._onDidChangeSharedWithAgent.event; private readonly _onWillDispose = this._register(new Emitter()); readonly onWillDispose: Event = this._onWillDispose.event; @@ -145,7 +153,8 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @IWorkspaceTrustManagementService private readonly workspaceTrustManagementService: IWorkspaceTrustManagementService, @ITelemetryService private readonly telemetryService: ITelemetryService, - @IConfigurationService private readonly configurationService: IConfigurationService + @IConfigurationService private readonly configurationService: IConfigurationService, + @IPlaywrightService private readonly playwrightService: IPlaywrightService ) { super(); } @@ -162,6 +171,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { get screenshot(): VSBuffer | undefined { return this._screenshot; } get error(): IBrowserViewLoadError | undefined { return this._error; } get storageScope(): BrowserViewStorageScope { return this._storageScope; } + get sharedWithAgent(): boolean { return this._sharedWithAgent; } get onDidNavigate(): Event { return this.browserViewService.onDynamicDidNavigate(this.id); @@ -239,6 +249,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { this._favicon = state.lastFavicon; this._error = state.lastError; this._storageScope = state.storageScope; + this._sharedWithAgent = await this.playwrightService.isPageTracked(this.id); // Set up state synchronization @@ -277,6 +288,10 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { this._register(this.onDidChangeVisibility(({ visible }) => { this._visible = visible; })); + + this._register(this.playwrightService.onDidChangeTrackedPages(ids => { + this._setSharedWithAgent(ids.includes(this.id)); + })); } async layout(bounds: IBrowserViewBounds): Promise { @@ -345,6 +360,21 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { return this.browserViewService.clearStorage(this.id); } + async setSharedWithAgent(shared: boolean): Promise { + if (shared) { + await this.playwrightService.startTrackingPage(this.id); + } else { + await this.playwrightService.stopTrackingPage(this.id); + } + } + + private _setSharedWithAgent(isShared: boolean): void { + if (isShared !== this._sharedWithAgent) { + this._sharedWithAgent = isShared; + this._onDidChangeSharedWithAgent.fire(isShared); + } + } + /** * Log navigation telemetry event */ From cfb055455a75facbc50ce667f7894eef19e81853 Mon Sep 17 00:00:00 2001 From: Kyle Cutler Date: Fri, 20 Feb 2026 16:23:46 -0800 Subject: [PATCH 2/5] feedback, fix build --- package-lock.json | 27 ++++++++++--------- package.json | 2 +- .../browserView/common/playwrightService.ts | 2 +- .../browserView/node/playwrightService.ts | 7 ++--- .../browserView/node/playwrightTab.ts | 4 +-- 5 files changed, 22 insertions(+), 20 deletions(-) diff --git a/package-lock.json b/package-lock.json index ebb7c60e934719..bc6f671c3e2162 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", + "@vscode/playwright-core": "npm:playwright-core@1.59.0-alpha-2026-02-20", "@vscode/policy-watcher": "^1.3.2", "@vscode/proxy-agent": "^0.38.0", "@vscode/ripgrep": "^1.15.13", @@ -50,7 +51,6 @@ "native-keymap": "^3.3.5", "node-pty": "^1.2.0-beta.10", "open": "^10.1.2", - "playwright-core": "1.59.0-alpha-2026-02-20", "tas-client": "0.3.1", "undici": "^7.18.2", "v8-inspect-profiler": "^0.1.1", @@ -3389,6 +3389,19 @@ "hasInstallScript": true, "license": "MIT" }, + "node_modules/@vscode/playwright-core": { + "name": "playwright-core", + "version": "1.59.0-alpha-2026-02-20", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.0-alpha-2026-02-20.tgz", + "integrity": "sha512-BK7oUBgMSbxfkQ579s270t0EkEyT2L2DA7qfMV4kaHanQOO0UK4mfyVLpWQsa+vUr/l7LxJGWsKlWcXD2QU9NQ==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@vscode/policy-watcher": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/@vscode/policy-watcher/-/policy-watcher-1.3.7.tgz", @@ -14003,18 +14016,6 @@ "fsevents": "2.3.2" } }, - "node_modules/playwright-core": { - "version": "1.59.0-alpha-2026-02-20", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.0-alpha-2026-02-20.tgz", - "integrity": "sha512-BK7oUBgMSbxfkQ579s270t0EkEyT2L2DA7qfMV4kaHanQOO0UK4mfyVLpWQsa+vUr/l7LxJGWsKlWcXD2QU9NQ==", - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/playwright/node_modules/playwright-core": { "version": "1.58.2", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", diff --git a/package.json b/package.json index f7eda48a971eb0..50c066ae74df3f 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", + "@vscode/playwright-core": "npm:playwright-core@1.59.0-alpha-2026-02-20", "@vscode/policy-watcher": "^1.3.2", "@vscode/proxy-agent": "^0.38.0", "@vscode/ripgrep": "^1.15.13", @@ -116,7 +117,6 @@ "native-keymap": "^3.3.5", "node-pty": "^1.2.0-beta.10", "open": "^10.1.2", - "playwright-core": "1.59.0-alpha-2026-02-20", "tas-client": "0.3.1", "undici": "^7.18.2", "v8-inspect-profiler": "^0.1.1", diff --git a/src/vs/platform/browserView/common/playwrightService.ts b/src/vs/platform/browserView/common/playwrightService.ts index 14e2c3d589a5bc..61231308cbcffe 100644 --- a/src/vs/platform/browserView/common/playwrightService.ts +++ b/src/vs/platform/browserView/common/playwrightService.ts @@ -70,7 +70,7 @@ export interface IPlaywrightService { * @param args Additional arguments to pass to the function after the `page` object. * @returns The result of the function execution, including a page summary. */ - invokeFunction(pageId: string, fnDef: string, ...args: unknown[]): Promise<{ result: TResult; summary: string }>; + invokeFunction(pageId: string, fnDef: string, ...args: unknown[]): Promise<{ result: unknown; summary: string }>; /** * Takes a screenshot of the given page and returns it as a VSBuffer. diff --git a/src/vs/platform/browserView/node/playwrightService.ts b/src/vs/platform/browserView/node/playwrightService.ts index 60c3eea9ace932..dbb457ba8a19eb 100644 --- a/src/vs/platform/browserView/node/playwrightService.ts +++ b/src/vs/platform/browserView/node/playwrightService.ts @@ -14,7 +14,7 @@ import { VSBuffer } from '../../../base/common/buffer.js'; import { PlaywrightTab } from './playwrightTab.js'; // eslint-disable-next-line local/code-import-patterns -import type { Browser, BrowserContext, Page } from 'playwright-core'; +import type { Browser, BrowserContext, Page } from 'playwright-core-for-agents'; /** * Shared-process implementation of {@link IPlaywrightService}. @@ -80,7 +80,7 @@ export class PlaywrightService extends Disposable implements IPlaywrightService const group = await this.browserViewGroupRemoteService.createGroup(); this.logService.debug('[PlaywrightService] Connecting to browser via CDP'); - const playwright = await import('playwright-core'); + const playwright = await import('playwright-core-for-agents'); const endpoint = await group.getDebugWebSocketEndpoint(); const browser = await playwright.chromium.connectOverCDP(endpoint); @@ -89,6 +89,7 @@ export class PlaywrightService extends Disposable implements IPlaywrightService // This can happen if the service was disposed while we were waiting for the connection. In that case, clean up immediately. if (this._initPromise === undefined) { browser.close().catch(() => { /* ignore */ }); + group.dispose(); throw new Error('PlaywrightService was disposed during initialization'); } @@ -126,7 +127,7 @@ export class PlaywrightService extends Disposable implements IPlaywrightService return this._pages.getSummary(page, true); } - async invokeFunction(pageId: string, fnDef: string, ...args: TArgs): Promise<{ result: TReturn; summary: string }> { + async invokeFunction(pageId: string, fnDef: string, ...args: unknown[]): Promise<{ result: unknown; summary: string }> { this.logService.info(`[PlaywrightService] Invoking function on view ${pageId}`); try { diff --git a/src/vs/platform/browserView/node/playwrightTab.ts b/src/vs/platform/browserView/node/playwrightTab.ts index 4cbe413b63b556..d133520934bdcb 100644 --- a/src/vs/platform/browserView/node/playwrightTab.ts +++ b/src/vs/platform/browserView/node/playwrightTab.ts @@ -4,12 +4,12 @@ *--------------------------------------------------------------------------------------------*/ // eslint-disable-next-line local/code-import-patterns -import type * as playwright from 'playwright-core'; +import type * as playwright from 'playwright-core-for-agents'; import { Emitter, Event } from '../../../base/common/event.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { createCancelablePromise, raceCancellablePromises } from '../../../base/common/async.js'; -declare module 'playwright-core' { +declare module 'playwright-core-for-agents' { interface Page { // A hidden Playwright method that returns an AI-friendly snapshot of the page. _snapshotForAI(options?: { track?: string }): Promise<{ full: string; incremental?: string }>; From 4079ae38e48c0399c9f5499e343e67b1d66a053a Mon Sep 17 00:00:00 2001 From: Kyle Cutler Date: Fri, 20 Feb 2026 16:26:25 -0800 Subject: [PATCH 3/5] fix --- src/vs/platform/browserView/node/playwrightService.ts | 4 ++-- src/vs/platform/browserView/node/playwrightTab.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/vs/platform/browserView/node/playwrightService.ts b/src/vs/platform/browserView/node/playwrightService.ts index dbb457ba8a19eb..2f25dadd50aaa3 100644 --- a/src/vs/platform/browserView/node/playwrightService.ts +++ b/src/vs/platform/browserView/node/playwrightService.ts @@ -14,7 +14,7 @@ import { VSBuffer } from '../../../base/common/buffer.js'; import { PlaywrightTab } from './playwrightTab.js'; // eslint-disable-next-line local/code-import-patterns -import type { Browser, BrowserContext, Page } from 'playwright-core-for-agents'; +import type { Browser, BrowserContext, Page } from '@vscode/playwright-core'; /** * Shared-process implementation of {@link IPlaywrightService}. @@ -80,7 +80,7 @@ export class PlaywrightService extends Disposable implements IPlaywrightService const group = await this.browserViewGroupRemoteService.createGroup(); this.logService.debug('[PlaywrightService] Connecting to browser via CDP'); - const playwright = await import('playwright-core-for-agents'); + const playwright = await import('@vscode/playwright-core'); const endpoint = await group.getDebugWebSocketEndpoint(); const browser = await playwright.chromium.connectOverCDP(endpoint); diff --git a/src/vs/platform/browserView/node/playwrightTab.ts b/src/vs/platform/browserView/node/playwrightTab.ts index d133520934bdcb..d2c0e81d8fe901 100644 --- a/src/vs/platform/browserView/node/playwrightTab.ts +++ b/src/vs/platform/browserView/node/playwrightTab.ts @@ -4,12 +4,12 @@ *--------------------------------------------------------------------------------------------*/ // eslint-disable-next-line local/code-import-patterns -import type * as playwright from 'playwright-core-for-agents'; +import type * as playwright from '@vscode/playwright-core'; import { Emitter, Event } from '../../../base/common/event.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { createCancelablePromise, raceCancellablePromises } from '../../../base/common/async.js'; -declare module 'playwright-core-for-agents' { +declare module '@vscode/playwright-core' { interface Page { // A hidden Playwright method that returns an AI-friendly snapshot of the page. _snapshotForAI(options?: { track?: string }): Promise<{ full: string; incremental?: string }>; From 776b9474eb45aed5d2aefea757e5fa2be1c31902 Mon Sep 17 00:00:00 2001 From: Kyle Cutler Date: Fri, 20 Feb 2026 16:40:03 -0800 Subject: [PATCH 4/5] fix --- package-lock.json | 73 +++++-------------- package.json | 4 +- .../browserView/node/playwrightService.ts | 4 +- .../browserView/node/playwrightTab.ts | 4 +- 4 files changed, 26 insertions(+), 59 deletions(-) diff --git a/package-lock.json b/package-lock.json index bc6f671c3e2162..d39d44fa5ef6e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,6 @@ "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", - "@vscode/playwright-core": "npm:playwright-core@1.59.0-alpha-2026-02-20", "@vscode/policy-watcher": "^1.3.2", "@vscode/proxy-agent": "^0.38.0", "@vscode/ripgrep": "^1.15.13", @@ -51,6 +50,7 @@ "native-keymap": "^3.3.5", "node-pty": "^1.2.0-beta.10", "open": "^10.1.2", + "playwright-core": "1.59.0-alpha-2026-02-20", "tas-client": "0.3.1", "undici": "^7.18.2", "v8-inspect-profiler": "^0.1.1", @@ -2074,38 +2074,6 @@ "node": ">=18" } }, - "node_modules/@playwright/test/node_modules/playwright": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", - "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.56.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/@playwright/test/node_modules/playwright-core": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", - "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@pondwader/socks5-server": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/@pondwader/socks5-server/-/socks5-server-1.0.10.tgz", @@ -3389,19 +3357,6 @@ "hasInstallScript": true, "license": "MIT" }, - "node_modules/@vscode/playwright-core": { - "name": "playwright-core", - "version": "1.59.0-alpha-2026-02-20", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.0-alpha-2026-02-20.tgz", - "integrity": "sha512-BK7oUBgMSbxfkQ579s270t0EkEyT2L2DA7qfMV4kaHanQOO0UK4mfyVLpWQsa+vUr/l7LxJGWsKlWcXD2QU9NQ==", - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@vscode/policy-watcher": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/@vscode/policy-watcher/-/policy-watcher-1.3.7.tgz", @@ -13998,13 +13953,13 @@ } }, "node_modules/playwright": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", - "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", + "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.58.2" + "playwright-core": "1.56.1" }, "bin": { "playwright": "cli.js" @@ -14016,10 +13971,22 @@ "fsevents": "2.3.2" } }, + "node_modules/playwright-core": { + "version": "1.59.0-alpha-2026-02-20", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.0-alpha-2026-02-20.tgz", + "integrity": "sha512-BK7oUBgMSbxfkQ579s270t0EkEyT2L2DA7qfMV4kaHanQOO0UK4mfyVLpWQsa+vUr/l7LxJGWsKlWcXD2QU9NQ==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/playwright/node_modules/playwright-core": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", - "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", + "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index 50c066ae74df3f..45224c475c792e 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,6 @@ "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", - "@vscode/playwright-core": "npm:playwright-core@1.59.0-alpha-2026-02-20", "@vscode/policy-watcher": "^1.3.2", "@vscode/proxy-agent": "^0.38.0", "@vscode/ripgrep": "^1.15.13", @@ -117,6 +116,7 @@ "native-keymap": "^3.3.5", "node-pty": "^1.2.0-beta.10", "open": "^10.1.2", + "playwright-core": "1.59.0-alpha-2026-02-20", "tas-client": "0.3.1", "undici": "^7.18.2", "v8-inspect-profiler": "^0.1.1", @@ -245,4 +245,4 @@ "optionalDependencies": { "windows-foreground-love": "0.6.1" } -} \ No newline at end of file +} diff --git a/src/vs/platform/browserView/node/playwrightService.ts b/src/vs/platform/browserView/node/playwrightService.ts index 2f25dadd50aaa3..7ef25b1ac3be93 100644 --- a/src/vs/platform/browserView/node/playwrightService.ts +++ b/src/vs/platform/browserView/node/playwrightService.ts @@ -14,7 +14,7 @@ import { VSBuffer } from '../../../base/common/buffer.js'; import { PlaywrightTab } from './playwrightTab.js'; // eslint-disable-next-line local/code-import-patterns -import type { Browser, BrowserContext, Page } from '@vscode/playwright-core'; +import type { Browser, BrowserContext, Page } from 'playwright-core'; /** * Shared-process implementation of {@link IPlaywrightService}. @@ -80,7 +80,7 @@ export class PlaywrightService extends Disposable implements IPlaywrightService const group = await this.browserViewGroupRemoteService.createGroup(); this.logService.debug('[PlaywrightService] Connecting to browser via CDP'); - const playwright = await import('@vscode/playwright-core'); + const playwright = await import('playwright-core'); const endpoint = await group.getDebugWebSocketEndpoint(); const browser = await playwright.chromium.connectOverCDP(endpoint); diff --git a/src/vs/platform/browserView/node/playwrightTab.ts b/src/vs/platform/browserView/node/playwrightTab.ts index d2c0e81d8fe901..4cbe413b63b556 100644 --- a/src/vs/platform/browserView/node/playwrightTab.ts +++ b/src/vs/platform/browserView/node/playwrightTab.ts @@ -4,12 +4,12 @@ *--------------------------------------------------------------------------------------------*/ // eslint-disable-next-line local/code-import-patterns -import type * as playwright from '@vscode/playwright-core'; +import type * as playwright from 'playwright-core'; import { Emitter, Event } from '../../../base/common/event.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { createCancelablePromise, raceCancellablePromises } from '../../../base/common/async.js'; -declare module '@vscode/playwright-core' { +declare module 'playwright-core' { interface Page { // A hidden Playwright method that returns an AI-friendly snapshot of the page. _snapshotForAI(options?: { track?: string }): Promise<{ full: string; incremental?: string }>; From 8ccbd37b130162d24845f5db072fa38005da4bea Mon Sep 17 00:00:00 2001 From: Kyle Cutler Date: Sun, 22 Feb 2026 09:57:46 -0800 Subject: [PATCH 5/5] feedback --- .../browserView/common/playwrightService.ts | 4 +- .../browserView/node/playwrightService.ts | 99 +++++++++-------- .../browserView/node/playwrightTab.ts | 103 ++++++++++-------- 3 files changed, 111 insertions(+), 95 deletions(-) diff --git a/src/vs/platform/browserView/common/playwrightService.ts b/src/vs/platform/browserView/common/playwrightService.ts index 61231308cbcffe..b49c50b5fb388b 100644 --- a/src/vs/platform/browserView/common/playwrightService.ts +++ b/src/vs/platform/browserView/common/playwrightService.ts @@ -73,9 +73,9 @@ export interface IPlaywrightService { invokeFunction(pageId: string, fnDef: string, ...args: unknown[]): Promise<{ result: unknown; summary: string }>; /** - * Takes a screenshot of the given page and returns it as a VSBuffer. + * Takes a screenshot of the current page viewport and returns it as a VSBuffer. * @param pageId The browser view ID identifying the page to capture. - * @param selector Optional Playwright selector to capture a specific element instead of the full page. + * @param selector Optional Playwright selector to capture a specific element instead of the viewport. * @param fullPage Whether to capture the full scrollable page instead of just the viewport. * @returns The screenshot image data. */ diff --git a/src/vs/platform/browserView/node/playwrightService.ts b/src/vs/platform/browserView/node/playwrightService.ts index 7ef25b1ac3be93..a2b807cf01b27f 100644 --- a/src/vs/platform/browserView/node/playwrightService.ts +++ b/src/vs/platform/browserView/node/playwrightService.ts @@ -115,16 +115,14 @@ export class PlaywrightService extends Disposable implements IPlaywrightService async openPage(url: string): Promise<{ pageId: string; summary: string }> { await this.initialize(); - const { page, viewId } = await this._pages.newPage(); - await page.goto(url, { waitUntil: 'domcontentloaded' }); - const summary = await this._pages.getSummary(page); - return { pageId: viewId, summary }; + const pageId = await this._pages.newPage(url); + const summary = await this._pages.getSummary(pageId); + return { pageId, summary }; } async getSummary(pageId: string): Promise { await this.initialize(); - const page = await this._pages.getPage(pageId); - return this._pages.getSummary(page, true); + return this._pages.getSummary(pageId, true); } async invokeFunction(pageId: string, fnDef: string, ...args: unknown[]): Promise<{ result: unknown; summary: string }> { @@ -132,19 +130,18 @@ export class PlaywrightService extends Disposable implements IPlaywrightService try { await this.initialize(); - const page = await this._pages.getPage(pageId); const vm = await import('vm'); const fn = vm.compileFunction(`return (${fnDef})(page, ...args)`, ['page', 'args'], { parsingContext: vm.createContext() }); let result; try { - result = await this._pages.runAndWaitForCompletion(page, () => fn(page, args)); + result = await this._pages.runAgainstPage(pageId, (page) => fn(page, args)); } catch (err: unknown) { result = err instanceof Error ? err.message : String(err); } - const summary = await this._pages.getSummary(page); + const summary = await this._pages.getSummary(pageId); return { result, summary }; } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err); @@ -155,27 +152,23 @@ export class PlaywrightService extends Disposable implements IPlaywrightService async captureScreenshot(pageId: string, selector?: string, fullPage?: boolean): Promise { await this.initialize(); - const page = await this._pages.getPage(pageId); - if (selector) { - const element = page.locator(selector); - const screenshotBuffer = await element.screenshot({ type: 'jpeg', quality: 80 }); + return this._pages.runAgainstPage(pageId, async page => { + const screenshotBuffer = selector + ? await page.locator(selector).screenshot({ type: 'jpeg', quality: 80 }) + : await page.screenshot({ type: 'jpeg', quality: 80, fullPage: fullPage ?? false }); return VSBuffer.wrap(screenshotBuffer); - } - const screenshotBuffer = await page.screenshot({ type: 'jpeg', quality: 80, fullPage: fullPage ?? false }); - return VSBuffer.wrap(screenshotBuffer); + }); } async replyToFileChooser(pageId: string, files: string[]): Promise<{ summary: string }> { await this.initialize(); - const page = await this._pages.getPage(pageId); - const summary = await this._pages.replyToFileChooser(page, files); + const summary = await this._pages.replyToFileChooser(pageId, files); return { summary }; } async replyToDialog(pageId: string, accept: boolean, promptText?: string): Promise<{ summary: string }> { await this.initialize(); - const page = await this._pages.getPage(pageId); - const summary = await this._pages.replyToDialog(page, accept, promptText); + const summary = await this._pages.replyToDialog(pageId, accept, promptText); return { summary }; } @@ -285,10 +278,10 @@ class PlaywrightPageManager extends Disposable { // --- Public: Playwright operations (require initialization) --- /** - * Create a new page in the browser and return its associated page and view ID. + * Create a new page in the browser and return its associated page ID. * The page is automatically added to the tracked set. */ - async newPage(): Promise<{ viewId: string; page: Page }> { + async newPage(url: string): Promise { if (!this._browser) { throw new Error('PlaywrightPageManager has not been initialized'); } @@ -299,41 +292,22 @@ class PlaywrightPageManager extends Disposable { this._trackedPages.add(viewId); this._fireTrackedPagesChanged(); - return { viewId, page }; - } - - /** - * Get the Playwright {@link Page} for a browser view. - * If the view is tracked but not yet connected, it is added to the group - * automatically. Throws if the view has not been added. - */ - async getPage(viewId: string): Promise { - const resolved = this._viewIdToPage.get(viewId); - if (resolved) { - return resolved; - } - const queued = this._viewIdQueue.find(item => item.viewId === viewId); - if (queued) { - return queued.page.p; - } - - if (this._trackedPages.has(viewId) && this._group) { - await this._addPageToGroup(viewId); - return this.getPage(viewId); - } + await page.goto(url, { waitUntil: 'domcontentloaded' }); - throw new Error(`Page "${viewId}" has not been added to the Playwright service`); + return viewId; } - async runAndWaitForCompletion(page: Page, callback: () => T | Promise): Promise { + async runAgainstPage(pageId: string, callback: (page: Page) => T | Promise): Promise { + 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.runAndWaitForCompletion(async () => callback()); + return tab.safeRunAgainstPage(async () => callback(page)); } - getSummary(page: Page, full = false): Promise { + async getSummary(pageId: string, full = false): Promise { + const page = await this.getPage(pageId); const tab = this._tabs.get(page); if (!tab) { throw new Error('Failed to get page summary'); @@ -341,7 +315,8 @@ class PlaywrightPageManager extends Disposable { return tab.getSummary(full); } - async replyToDialog(page: Page, accept: boolean, promptText?: string): Promise { + async replyToDialog(pageId: string, accept: boolean, promptText?: string): Promise { + const page = await this.getPage(pageId); const tab = this._tabs.get(page); if (!tab) { throw new Error('Failed to reply to dialog'); @@ -350,7 +325,8 @@ class PlaywrightPageManager extends Disposable { return tab.getSummary(); } - async replyToFileChooser(page: Page, files: string[]): Promise { + async replyToFileChooser(pageId: string, files: string[]): Promise { + const page = await this.getPage(pageId); const tab = this._tabs.get(page); if (!tab) { throw new Error('Failed to reply to file chooser'); @@ -443,6 +419,29 @@ class PlaywrightPageManager extends Disposable { // --- Page matching (view ↔ page pairing) --- + /** + * Get the Playwright {@link Page} for a browser view. + * If the view is tracked but not yet connected, it is added to the group + * automatically. Throws if the view has not been added. + */ + private async getPage(viewId: string): Promise { + const resolved = this._viewIdToPage.get(viewId); + if (resolved) { + return resolved; + } + const queued = this._viewIdQueue.find(item => item.viewId === viewId); + if (queued) { + return queued.page.p; + } + + if (this._trackedPages.has(viewId) && this._group) { + await this._addPageToGroup(viewId); + return this.getPage(viewId); + } + + throw new Error(`Page "${viewId}" has not been added to the Playwright service`); + } + /** * Called when the group fires onDidAddView. Creates a deferred entry in * the view ID queue and attempts to match it with a page. diff --git a/src/vs/platform/browserView/node/playwrightTab.ts b/src/vs/platform/browserView/node/playwrightTab.ts index 4cbe413b63b556..231daf0fba0470 100644 --- a/src/vs/platform/browserView/node/playwrightTab.ts +++ b/src/vs/platform/browserView/node/playwrightTab.ts @@ -28,10 +28,17 @@ export class PlaywrightTab { private _dialog: playwright.Dialog | undefined; private _fileChooser: playwright.FileChooser | undefined; private _logs: { type: string; time: number; description: string }[] = []; + private _needsFullSnapshot = false; private _initialized: Promise; - constructor(readonly page: playwright.Page) { + constructor( + /** + * @deprecated prefer accessing the page via safeRunAgainstPage. + * Only use this directly if you are sure it cannot be blocked by dialogs. + */ + private readonly page: playwright.Page + ) { page.on('console', event => this._handleConsoleMessage(event)) .on('pageerror', error => this._handlePageError(error)) .on('requestfailed', request => this._handleRequestFailed(request)) @@ -68,7 +75,7 @@ export class PlaywrightTab { const dialog = this._dialog; this._dialog = undefined; this._onDialogStateChanged.fire(); - await this.runAndWaitForCompletion(async () => { + await this.safeRunAgainstPage(async () => { if (accept) { await dialog.accept(promptText); } else { @@ -87,7 +94,7 @@ export class PlaywrightTab { } const chooser = this._fileChooser; this._fileChooser = undefined; - await this.runAndWaitForCompletion(() => chooser.setFiles(files)); + await this.safeRunAgainstPage(() => chooser.setFiles(files)); } private async _handleDownload(download: playwright.Download) { @@ -109,22 +116,26 @@ export class PlaywrightTab { this._logs.push({ type: 'pageError', time: Date.now(), description: error.stack ?? error.message }); } - async runAndWaitForCompletion(callback: (token: CancellationToken) => Promise): Promise { - // Dialogs pause the page. So we run the callback and wait for either its completion or a dialog to open + /** + * Run a callback against the page and wait for it to complete. + * Because dialogs pause the page, execution races against any dialog that opens -- if a dialog + * appears before the callback finishes, the method throws so the caller can surface it to the agent. + */ + async safeRunAgainstPage(action: (page: playwright.Page, token: CancellationToken) => Promise): Promise { if (this._dialog) { throw new Error(`Cannot perform action while a dialog is open`); } - let completed = false; + let actionDidComplete = false; let result: T | void; - const dialog = Event.toPromise(this._onDialogStateChanged.event); - const action = createCancelablePromise(async (token) => { - result = await runAndWaitForCompletion(this, callback, token); - completed = true; + const dialogOpened = Event.toPromise(this._onDialogStateChanged.event); + const actionCompleted = createCancelablePromise(async (token) => { + result = await this.runAndWaitForCompletion((token) => action(this.page, token), token); + actionDidComplete = true; }); - return raceCancellablePromises([dialog, action]).then(() => { - if (!completed) { + return raceCancellablePromises([dialogOpened, actionCompleted]).then(() => { + if (!actionDidComplete) { // A dialog was opened before the action completed. Note we don't cancel the action, just ignore its result. throw new Error('Action was interrupted by a dialog'); } @@ -132,12 +143,18 @@ export class PlaywrightTab { }); } - async getSummary(full = false): Promise { + async getSummary(full = this._needsFullSnapshot): Promise { await this._initialized; - // Important: anything run against the page must be wrapped with runAndWaitForCompletion in case dialogs are opened. - const snapshotFromPage = await this.runAndWaitForCompletion(() => this.page._snapshotForAI({ track: 'response' })).catch(() => undefined); - const title = await this.runAndWaitForCompletion(() => this.page.title()).catch(() => ''); + if (full && this._needsFullSnapshot) { + this._needsFullSnapshot = false; + } + + const snapshotFromPage = await this.safeRunAgainstPage((page) => page._snapshotForAI({ track: 'response' })).catch(() => { + this._needsFullSnapshot = true; + return undefined; + }); + const title = await this.safeRunAgainstPage((page) => page.title()).catch(() => ''); const logs = this._logs; this._logs = []; @@ -156,37 +173,37 @@ export class PlaywrightTab { ...(snapshot ? ['Snapshot:', snapshot] : []) ].join('\n'); } -} -async function runAndWaitForCompletion(tab: PlaywrightTab, callback: (token: CancellationToken) => Promise, token = CancellationToken.None): Promise { - const requests: playwright.Request[] = []; + private async runAndWaitForCompletion(callback: (token: CancellationToken) => Promise, token = CancellationToken.None): Promise { + const requests: playwright.Request[] = []; - const requestListener = (request: playwright.Request) => requests.push(request); - const disposeListeners = () => { - tab.page.off('request', requestListener); - }; - tab.page.on('request', requestListener); + const requestListener = (request: playwright.Request) => requests.push(request); + const disposeListeners = () => { + this.page.off('request', requestListener); + }; + this.page.on('request', requestListener); - let result: T; - try { - result = await callback(token); - } finally { - disposeListeners(); - } + let result: T; + try { + result = await callback(token); + } finally { + disposeListeners(); + } - const requestedNavigation = requests.some(request => request.isNavigationRequest()); - if (requestedNavigation) { - await tab.page.mainFrame().waitForLoadState('load', { timeout: 10000 }).catch(() => { }); - return result; - } + const requestedNavigation = requests.some(request => request.isNavigationRequest()); + if (requestedNavigation) { + await this.page.mainFrame().waitForLoadState('load', { timeout: 10000 }).catch(() => { }); + return result; + } - const promises: Promise[] = []; - for (const request of requests) { - if (['document', 'stylesheet', 'script', 'xhr', 'fetch'].includes(request.resourceType())) { promises.push(request.response().then(r => r?.finished()).catch(() => { })); } - else { promises.push(request.response().catch(() => { })); } - } - const timeout = new Promise(resolve => setTimeout(resolve, 5000)); - await Promise.race([Promise.all(promises), timeout]); + const promises: Promise[] = []; + for (const request of requests) { + if (['document', 'stylesheet', 'script', 'xhr', 'fetch'].includes(request.resourceType())) { promises.push(request.response().then(r => r?.finished()).catch(() => { })); } + else { promises.push(request.response().catch(() => { })); } + } + const timeout = new Promise(resolve => setTimeout(resolve, 5000)); + await Promise.race([Promise.all(promises), timeout]); - return result; + return result; + } }