Repository navigation
feat(mcp): allow creating tabs in isolated browser contexts #43087
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,7 +22,7 @@ import debug from 'debug'; | |
| import { escapeWithQuotes } from '@isomorphic/stringUtils'; | ||
| import { disposeAll } from '@isomorphic/disposable'; | ||
| import { eventsHelper } from '@utils/eventsHelper'; | ||
| import { isPathInside, isSystemDirectory, isWritable, resolveSymlinks } from '@utils/fileUtils'; | ||
| import { isPathInside, isSystemDirectory, isWritable, resolveSymlinks, sanitizeForFilePath } from '@utils/fileUtils'; | ||
| import { playwright } from '../../inprocess'; | ||
|
|
||
| import { dedent, languageGeneratorId, secretCode } from './codegen'; | ||
|
|
@@ -67,6 +67,7 @@ export type ContextConfig = { | |
| settle?: number; | ||
| }; | ||
| browser?: { | ||
| contextOptions?: playwrightTypes.BrowserContextOptions; | ||
| initScript?: string[]; | ||
| initPage?: string[]; | ||
| }; | ||
|
|
@@ -99,15 +100,19 @@ export type FilenameTemplate = { | |
|
|
||
| type VideoParams = { size?: { width: number; height: number }, fps?: number, cursor?: boolean }; | ||
|
|
||
| export type TraceInfo = { name: string, isolatedContext?: string }; | ||
|
|
||
| // Actions are paced by this delay when the cursor is shown, giving it time to travel. | ||
| const kCursorDuration = 800; | ||
|
|
||
| export class Context { | ||
| readonly config: ContextConfig; | ||
| readonly sessionLog: SessionLog | undefined; | ||
| readonly options: ContextOptions; | ||
| private _rawBrowserContext: playwrightTypes.BrowserContext; | ||
| private _browserContextPromise: Promise<playwrightTypes.BrowserContext> | undefined; | ||
| private _defaultBrowserContext: playwrightTypes.BrowserContext; | ||
| // Keyed by isolated context name, undefined for the default context. | ||
| // Resolves once the context exists and has routes, init scripts and page listeners installed. | ||
| private _browserContexts = new Map<string | undefined, Promise<playwrightTypes.BrowserContext>>(); | ||
| private _tabs: Tab[] = []; | ||
| private _currentTab: Tab | undefined; | ||
| private _routes: RouteEntry[] = []; | ||
|
|
@@ -116,7 +121,14 @@ export class Context { | |
| fileNames: string[]; | ||
| fileName: string; | ||
| } | undefined; | ||
| private _recordedActions: string[] | undefined; | ||
| private _recording: { | ||
| browserContext: playwrightTypes.BrowserContext; | ||
| actions: string[]; | ||
| } | undefined; | ||
| private _tracing: { | ||
| name: string; | ||
| traces: (TraceInfo & { browserContext: playwrightTypes.BrowserContext })[]; | ||
| } | undefined; | ||
| private _disposables: Disposable[] = []; | ||
|
|
||
| private _webmcpToolsSignature = ''; | ||
|
|
@@ -134,20 +146,24 @@ export class Context { | |
| this.config = options.config; | ||
| this.sessionLog = options.sessionLog; | ||
| this.options = options; | ||
| this._rawBrowserContext = browserContext; | ||
| this._defaultBrowserContext = browserContext; | ||
| if (this.config.testIdAttribute) | ||
| playwright.selectors.setTestIdAttribute(this.config.testIdAttribute); | ||
| testDebug('create context'); | ||
| process.on('unhandledRejection', this._onUnhandledRejection); | ||
| } | ||
|
|
||
| async dispose() { | ||
| process.off('unhandledRejection', this._onUnhandledRejection); | ||
| await this.stopRecording(); | ||
| await this.stopVideoRecording(); | ||
| await disposeAll(this._disposables); | ||
| for (const tab of this._tabs) | ||
| await tab.dispose(); | ||
| this._tabs.length = 0; | ||
| this._setCurrentTab(undefined); | ||
| await this.stopVideoRecording(); | ||
| await Promise.all([...this._browserContexts].filter(([isolatedContext]) => isolatedContext).map(([, browserContext]) => browserContext.then(c => c.close()).catch(() => {}))); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can context name be '' ? |
||
| this._browserContexts.clear(); | ||
| } | ||
|
|
||
| drainPendingUnhandledRejections(): unknown[] { | ||
|
|
@@ -162,7 +178,7 @@ export class Context { | |
| } | ||
|
|
||
| debugger() { | ||
| return this._rawBrowserContext.debugger; | ||
| return this._defaultBrowserContext.debugger; | ||
| } | ||
|
|
||
| tabs(): Tab[] { | ||
|
|
@@ -179,8 +195,8 @@ export class Context { | |
| return this._currentTab; | ||
| } | ||
|
|
||
| async newTab(): Promise<Tab> { | ||
| const browserContext = await this.ensureBrowserContext(); | ||
| async newTab(isolatedContext?: string): Promise<Tab> { | ||
| const browserContext = await this.ensureBrowserContext(isolatedContext); | ||
| const page = await browserContext.newPage(); | ||
| this._setCurrentTab(this._tabs.find(t => t.page === page)!); | ||
| return this._currentTab!; | ||
|
|
@@ -232,26 +248,26 @@ export class Context { | |
| async startVideoRecording(fileName: string, params: VideoParams) { | ||
| if (this._video) | ||
| throw new Error('Video recording has already been started.'); | ||
| await this.ensureBrowserContext(); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. will it also start video for a context created later?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Video is wired per page. |
||
| this._video = { params, fileName, fileNames: [] }; | ||
| const browserContext = await this.ensureBrowserContext(); | ||
| for (const page of browserContext.pages()) | ||
| await this._startPageVideo(page); | ||
| for (const tab of this._tabs) | ||
| await this._startPageVideo(tab.page); | ||
| } | ||
|
|
||
| async stopVideoRecording(): Promise<string[]> { | ||
| if (!this._video) | ||
| return []; | ||
| const video = this._video; | ||
| for (const page of this._rawBrowserContext.pages()) | ||
| await page.screencast.stop(); | ||
| for (const tab of this._tabs) | ||
| await tab.page.screencast.stop(); | ||
| this._video = undefined; | ||
| return [...video.fileNames]; | ||
| } | ||
|
|
||
| async startRecording() { | ||
| if (this._recordedActions) | ||
| if (this._recording) | ||
| throw new Error('Recording is already in progress.'); | ||
| const browserContext = await this.ensureBrowserContext() as BrowserContextEx; | ||
| const browserContext = await this.currentBrowserContext() as BrowserContextEx; | ||
| if (typeof browserContext._startRecording !== 'function') | ||
| throw new Error('Recording requires a newer version of Playwright, please upgrade.'); | ||
| const recordedActions: string[] = []; | ||
|
|
@@ -272,16 +288,46 @@ export class Context { | |
| recordedActions[recordedActions.length - 1] = code; | ||
| }, | ||
| }); | ||
| this._recordedActions = recordedActions; | ||
| this._recording = { browserContext, actions: recordedActions }; | ||
| } | ||
|
|
||
| async stopRecording(): Promise<string[] | undefined> { | ||
| const recordedActions = this._recordedActions; | ||
| if (!recordedActions) | ||
| const recording = this._recording; | ||
| if (!recording) | ||
| return undefined; | ||
| this._recording = undefined; | ||
| await (recording.browserContext as BrowserContextEx)._stopRecording(); | ||
| return recording.actions.filter(code => code.trim()).map(dedent); | ||
| } | ||
|
|
||
| async startTracing(): Promise<TraceInfo[]> { | ||
| if (this._tracing) | ||
| throw new Error('Tracing has already been started.'); | ||
| await this.ensureBrowserContext(); | ||
| const tracing = this._tracing = { name: 'trace-' + Date.now(), traces: [] }; | ||
| for (const [isolatedContext, browserContext] of this._browserContexts) | ||
| await this._startContextTracing(isolatedContext, await browserContext); | ||
| return tracing.traces; | ||
| } | ||
|
|
||
| async stopTracing(): Promise<TraceInfo[] | undefined> { | ||
| const tracing = this._tracing; | ||
| if (!tracing) | ||
| return undefined; | ||
| this._recordedActions = undefined; | ||
| await (this._rawBrowserContext as BrowserContextEx)._stopRecording(); | ||
| return recordedActions.filter(code => code.trim()).map(dedent); | ||
| this._tracing = undefined; | ||
| await Promise.all(tracing.traces.filter(trace => !trace.browserContext.isClosed()).map(trace => trace.browserContext.tracing.stop())); | ||
| return tracing.traces; | ||
| } | ||
|
|
||
| private async _startContextTracing(isolatedContext: string | undefined, browserContext: playwrightTypes.BrowserContext) { | ||
| const tracing = this._tracing; | ||
| if (!tracing || tracing.traces.some(trace => trace.browserContext === browserContext)) | ||
| return; | ||
| let name = isolatedContext ? `${tracing.name}-${sanitizeForFilePath(isolatedContext)}` : tracing.name; | ||
| if (tracing.traces.some(trace => trace.name === name)) | ||
| name += `-${tracing.traces.length}`; | ||
| tracing.traces.push({ name, isolatedContext, browserContext }); | ||
| await browserContext.tracing.start({ name, screenshots: true, snapshots: true, live: true }); | ||
| } | ||
|
|
||
| codegenLanguage(): CodegenLanguage { | ||
|
|
@@ -308,8 +354,8 @@ export class Context { | |
| } | ||
| } | ||
|
|
||
| private _onPageCreated(page: playwrightTypes.Page) { | ||
| const tab = new Tab(this, page, tab => this._onPageClosed(tab)); | ||
| private _onPageCreated(page: playwrightTypes.Page, isolatedContext: string | undefined) { | ||
| const tab = new Tab(this, page, isolatedContext, tab => this._onPageClosed(tab)); | ||
| this._tabs.push(tab); | ||
| if (!this._currentTab) | ||
| this._setCurrentTab(tab); | ||
|
|
@@ -351,27 +397,19 @@ export class Context { | |
| } | ||
|
|
||
| async addRoute(entry: RouteEntry): Promise<void> { | ||
| const browserContext = await this.ensureBrowserContext(); | ||
| await browserContext.route(entry.pattern, entry.handler); | ||
| for (const browserContext of await this._allBrowserContexts()) | ||
| await browserContext.route(entry.pattern, entry.handler); | ||
| this._routes.push(entry); | ||
| } | ||
|
|
||
| async removeRoute(pattern?: string): Promise<number> { | ||
| let removed = 0; | ||
| const browserContext = await this.ensureBrowserContext(); | ||
| if (pattern) { | ||
| const toRemove = this._routes.filter(r => r.pattern === pattern); | ||
| const toRemove = pattern ? this._routes.filter(r => r.pattern === pattern) : this._routes; | ||
| for (const browserContext of await this._allBrowserContexts()) { | ||
| for (const route of toRemove) | ||
| await browserContext.unroute(route.pattern, route.handler); | ||
| this._routes = this._routes.filter(r => r.pattern !== pattern); | ||
| removed = toRemove.length; | ||
| } else { | ||
| for (const route of this._routes) | ||
| await browserContext.unroute(route.pattern, route.handler); | ||
| removed = this._routes.length; | ||
| this._routes = []; | ||
| } | ||
| return removed; | ||
| this._routes = this._routes.filter(r => !toRemove.includes(r)); | ||
| return toRemove.length; | ||
| } | ||
|
|
||
| isRunningTool() { | ||
|
|
@@ -382,42 +420,69 @@ export class Context { | |
| this._runningToolName = name; | ||
| } | ||
|
|
||
| private async _setupRequestInterception(context: playwrightTypes.BrowserContext) { | ||
| private async _setupRequestInterception(context: playwrightTypes.BrowserContext, disposables: Disposable[]) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should it also add entries from
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yes, in _initializeBrowserContext |
||
| if (this.config.network?.allowedOrigins?.length) { | ||
| this._disposables.push(await context.route('**', route => route.abort('blockedbyclient'))); | ||
| disposables.push(await context.route('**', route => route.abort('blockedbyclient'))); | ||
|
|
||
| for (const origin of this.config.network.allowedOrigins) { | ||
| const glob = originOrHostGlob(origin); | ||
| this._disposables.push(await context.route(glob, route => route.continue())); | ||
| disposables.push(await context.route(glob, route => route.continue())); | ||
| } | ||
| } | ||
|
|
||
| if (this.config.network?.blockedOrigins?.length) { | ||
| for (const origin of this.config.network.blockedOrigins) | ||
| this._disposables.push(await context.route(originOrHostGlob(origin), route => route.abort('blockedbyclient'))); | ||
| disposables.push(await context.route(originOrHostGlob(origin), route => route.abort('blockedbyclient'))); | ||
| } | ||
| } | ||
|
|
||
| async ensureBrowserContext(): Promise<playwrightTypes.BrowserContext> { | ||
| if (this._browserContextPromise) | ||
| return this._browserContextPromise; | ||
| this._browserContextPromise = this._initializeBrowserContext(); | ||
| return this._browserContextPromise; | ||
| async ensureBrowserContext(isolatedContext?: string): Promise<playwrightTypes.BrowserContext> { | ||
| if (!isolatedContext || isolatedContext === 'default') | ||
| isolatedContext = undefined; | ||
| let browserContext = this._browserContexts.get(isolatedContext); | ||
| if (!browserContext) { | ||
| browserContext = this._initializeBrowserContext(isolatedContext); | ||
| this._browserContexts.set(isolatedContext, browserContext); | ||
| browserContext.catch(() => this._browserContexts.delete(isolatedContext)); | ||
| } | ||
| return await browserContext; | ||
| } | ||
|
|
||
| private async _initializeBrowserContext() { | ||
| if (this.config.testIdAttribute) | ||
| playwright.selectors.setTestIdAttribute(this.config.testIdAttribute); | ||
| const browserContext = this._rawBrowserContext; | ||
| await this._setupRequestInterception(browserContext); | ||
| async currentBrowserContext(): Promise<playwrightTypes.BrowserContext> { | ||
| return this._currentTab?.page.context() ?? await this.ensureBrowserContext(); | ||
| } | ||
|
|
||
| private async _allBrowserContexts(): Promise<playwrightTypes.BrowserContext[]> { | ||
| await this.ensureBrowserContext(); | ||
| return await Promise.all(this._browserContexts.values()); | ||
| } | ||
|
|
||
| private async _initializeBrowserContext(isolatedContext: string | undefined): Promise<playwrightTypes.BrowserContext> { | ||
| const browserContext = isolatedContext ? await this._createIsolatedContext(isolatedContext) : this._defaultBrowserContext; | ||
| // Isolated contexts are closed in dispose(), which tears down their routes, scripts and listeners. | ||
| const disposables = isolatedContext ? [] : this._disposables; | ||
| await this._setupRequestInterception(browserContext, disposables); | ||
|
|
||
| for (const initScript of this.config.browser?.initScript || []) | ||
| this._disposables.push(await browserContext.addInitScript({ path: path.resolve(this.options.cwd, initScript) })); | ||
| disposables.push(await browserContext.addInitScript({ path: path.resolve(this.options.cwd, initScript) })); | ||
|
|
||
| for (const route of this._routes) | ||
| await browserContext.route(route.pattern, route.handler); | ||
|
|
||
| for (const page of browserContext.pages()) | ||
| this._onPageCreated(page); | ||
| this._disposables.push(eventsHelper.addEventListener(browserContext, 'page', page => this._onPageCreated(page))); | ||
| this._onPageCreated(page, isolatedContext); | ||
| disposables.push(eventsHelper.addEventListener(browserContext, 'page', page => this._onPageCreated(page, isolatedContext))); | ||
| await this._startContextTracing(isolatedContext, browserContext); | ||
| return browserContext; | ||
| } | ||
|
|
||
| private async _createIsolatedContext(name: string): Promise<playwrightTypes.BrowserContext> { | ||
| await this.ensureBrowserContext(); | ||
| const browser = this._defaultBrowserContext.browser(); | ||
| if (!browser) | ||
| throw new Error('Isolated contexts are not supported for this browser.'); | ||
| const browserContext = await browser.newContext(this.config.browser?.contextOptions); | ||
| browserContext.once('close', () => this._browserContexts.delete(name)); | ||
| return browserContext; | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
drop the comment?