/** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import debug from 'debug'; import * as playwright from 'playwright'; import { devices } from 'playwright'; import { logUnhandledError } from './log.js'; import { Tab } from './tab.js'; import { EnvironmentIntrospector } from './environmentIntrospection.js'; import type { Tool } from './tools/tool.js'; import type { FullConfig } from './config.js'; import type { BrowserContextFactory } from './browserContextFactory.js'; const testDebug = debug('pw:mcp:test'); export class Context { readonly tools: Tool[]; readonly config: FullConfig; private _browserContextPromise: Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> | undefined; private _browserContextFactory: BrowserContextFactory; private _tabs: Tab[] = []; private _currentTab: Tab | undefined; clientVersion: { name: string; version: string; } | undefined; private _videoRecordingConfig: { dir: string; size?: { width: number; height: number } } | undefined; private _videoBaseFilename: string | undefined; private _activePagesWithVideos: Set = new Set(); private _environmentIntrospector: EnvironmentIntrospector; private static _allContexts: Set = new Set(); private _closeBrowserContextPromise: Promise | undefined; // Session isolation properties readonly sessionId: string; private _sessionStartTime: number; // Chrome extension management private _installedExtensions: Array<{ path: string; name: string; version?: string }> = []; // Differential snapshot tracking private _lastSnapshotFingerprint: string | undefined; private _lastPageState: { url: string; title: string } | undefined; constructor(tools: Tool[], config: FullConfig, browserContextFactory: BrowserContextFactory, environmentIntrospector?: EnvironmentIntrospector) { this.tools = tools; this.config = config; this._browserContextFactory = browserContextFactory; this._environmentIntrospector = environmentIntrospector || new EnvironmentIntrospector(); // Generate unique session ID this._sessionStartTime = Date.now(); this.sessionId = this._generateSessionId(); testDebug(`create context with sessionId: ${this.sessionId}`); Context._allContexts.add(this); } static async disposeAll() { await Promise.all([...Context._allContexts].map(context => context.dispose())); } private _generateSessionId(): string { // Create a base session ID from timestamp and random const baseId = `${this._sessionStartTime}-${Math.random().toString(36).substr(2, 9)}`; // If we have client version info, incorporate it if (this.clientVersion) { const clientInfo = `${this.clientVersion.name || 'unknown'}-${this.clientVersion.version || 'unknown'}`; return `${clientInfo}-${baseId}`; } return baseId; } updateSessionIdWithClientInfo() { if (this.clientVersion) { const newSessionId = this._generateSessionId(); testDebug(`updating sessionId from ${this.sessionId} to ${newSessionId}`); // Note: sessionId is readonly, but we can update it during initialization (this as any).sessionId = newSessionId; } } updateSessionId(customSessionId: string) { testDebug(`updating sessionId from ${this.sessionId} to ${customSessionId}`); // Note: sessionId is readonly, but we can update it for artifact management (this as any).sessionId = customSessionId; } tabs(): Tab[] { return this._tabs; } currentTab(): Tab | undefined { return this._currentTab; } currentTabOrDie(): Tab { if (!this._currentTab) throw new Error('No open pages available. Use the "browser_navigate" tool to navigate to a page first.'); return this._currentTab; } async newTab(): Promise { const { browserContext } = await this._ensureBrowserContext(); const page = await browserContext.newPage(); this._currentTab = this._tabs.find(t => t.page === page)!; return this._currentTab; } async selectTab(index: number) { const tab = this._tabs[index]; if (!tab) throw new Error(`Tab ${index} not found`); await tab.page.bringToFront(); this._currentTab = tab; return tab; } async ensureTab(): Promise { const { browserContext } = await this._ensureBrowserContext(); if (!this._currentTab) await browserContext.newPage(); return this._currentTab!; } async listTabsMarkdown(force: boolean = false): Promise { if (this._tabs.length === 1 && !force) return []; if (!this._tabs.length) { return [ '### No open tabs', 'Use the "browser_navigate" tool to navigate to a page first.', '', ]; } const lines: string[] = ['### Open tabs']; for (let i = 0; i < this._tabs.length; i++) { const tab = this._tabs[i]; const title = await tab.title(); const url = tab.page.url(); const current = tab === this._currentTab ? ' (current)' : ''; lines.push(`- ${i}:${current} [${title}] (${url})`); } lines.push(''); return lines; } async closeTab(index: number | undefined): Promise { const tab = index === undefined ? this._currentTab : this._tabs[index]; if (!tab) throw new Error(`Tab ${index} not found`); const url = tab.page.url(); await tab.page.close(); return url; } private _onPageCreated(page: playwright.Page) { const tab = new Tab(this, page, tab => this._onPageClosed(tab)); this._tabs.push(tab); if (!this._currentTab) this._currentTab = tab; // Track pages with video recording if (this._videoRecordingConfig && page.video()) this._activePagesWithVideos.add(page); } private _onPageClosed(tab: Tab) { const index = this._tabs.indexOf(tab); if (index === -1) return; this._tabs.splice(index, 1); if (this._currentTab === tab) this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)]; if (!this._tabs.length) void this.closeBrowserContext(); } async closeBrowserContext() { if (!this._closeBrowserContextPromise) this._closeBrowserContextPromise = this._closeBrowserContextImpl().catch(logUnhandledError); await this._closeBrowserContextPromise; this._closeBrowserContextPromise = undefined; } private async _closeBrowserContextImpl() { if (!this._browserContextPromise) return; testDebug('close context'); const promise = this._browserContextPromise; this._browserContextPromise = undefined; await promise.then(async ({ browserContext, close }) => { if (this.config.saveTrace) await browserContext.tracing.stop(); await close(); }); } async dispose() { await this.closeBrowserContext(); Context._allContexts.delete(this); } private async _setupRequestInterception(context: playwright.BrowserContext) { if (this.config.network?.allowedOrigins?.length) { await context.route('**', route => route.abort('blockedbyclient')); for (const origin of this.config.network.allowedOrigins) await context.route(`*://${origin}/**`, route => route.continue()); } if (this.config.network?.blockedOrigins?.length) { for (const origin of this.config.network.blockedOrigins) await context.route(`*://${origin}/**`, route => route.abort('blockedbyclient')); } } private _ensureBrowserContext() { if (!this._browserContextPromise) { this._browserContextPromise = this._setupBrowserContext(); this._browserContextPromise.catch(() => { this._browserContextPromise = undefined; }); } return this._browserContextPromise; } private async _setupBrowserContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> { if (this._closeBrowserContextPromise) throw new Error('Another browser context is being closed.'); let result: { browserContext: playwright.BrowserContext, close: () => Promise }; if (this._videoRecordingConfig) { // Create a new browser context with video recording enabled result = await this._createVideoEnabledContext(); } else { // Use the standard browser context factory result = await this._browserContextFactory.createContext(this.clientVersion!, this._getExtensionPaths()); } const { browserContext } = result; await this._setupRequestInterception(browserContext); for (const page of browserContext.pages()) this._onPageCreated(page); browserContext.on('page', page => this._onPageCreated(page)); if (this.config.saveTrace) { await browserContext.tracing.start({ name: 'trace', screenshots: false, snapshots: true, sources: false, }); } return result; } private async _createVideoEnabledContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> { // For video recording, we need to create an isolated context const browserType = playwright[this.config.browser.browserName]; // Get environment-specific browser options const envOptions = this._environmentIntrospector.getRecommendedBrowserOptions(); const launchOptions = { ...this.config.browser.launchOptions, ...envOptions, // Include environment-detected options handleSIGINT: false, handleSIGTERM: false, }; // Add Chrome extension support for Chromium const extensionPaths = this._getExtensionPaths(); if (this.config.browser.browserName === 'chromium' && extensionPaths.length > 0) { testDebug(`Loading ${extensionPaths.length} Chrome extensions in video context: ${extensionPaths.join(', ')}`); launchOptions.args = [ ...(launchOptions.args || []), ...extensionPaths.map(path => `--load-extension=${path}`) ]; } const browser = await browserType.launch(launchOptions); // Use environment-specific video directory if available const videoConfig = envOptions.recordVideo ? { ...this._videoRecordingConfig, dir: envOptions.recordVideo.dir } : this._videoRecordingConfig; const contextOptions = { ...this.config.browser.contextOptions, recordVideo: videoConfig, // Force isolated session for video recording with session-specific storage storageState: undefined, // Always start fresh for video recording }; const browserContext = await browser.newContext(contextOptions); return { browserContext, close: async () => { await browserContext.close(); await browser.close(); } }; } setVideoRecording(config: { dir: string; size?: { width: number; height: number } }, baseFilename: string) { this._videoRecordingConfig = config; this._videoBaseFilename = baseFilename; // Force recreation of browser context to include video recording if (this._browserContextPromise) { void this.closeBrowserContext().then(() => { // The next call to _ensureBrowserContext will create a new context with video recording }); } } getVideoRecordingInfo() { return { enabled: !!this._videoRecordingConfig, config: this._videoRecordingConfig, baseFilename: this._videoBaseFilename, activeRecordings: this._activePagesWithVideos.size, }; } updateEnvironmentRoots(roots: { uri: string; name?: string }[]) { this._environmentIntrospector.updateRoots(roots); // Log environment change const summary = this._environmentIntrospector.getEnvironmentSummary(); testDebug(`environment updated for session ${this.sessionId}: ${summary}`); // If we have an active browser context, we might want to recreate it // For now, we'll just log the change - full recreation would close existing tabs if (this._browserContextPromise) testDebug(`browser context exists - environment changes will apply to new contexts`); } getEnvironmentIntrospector(): EnvironmentIntrospector { return this._environmentIntrospector; } async updateBrowserConfig(changes: { headless?: boolean; viewport?: { width: number; height: number }; userAgent?: string; device?: string; geolocation?: { latitude: number; longitude: number; accuracy?: number }; locale?: string; timezone?: string; colorScheme?: 'light' | 'dark' | 'no-preference'; permissions?: string[]; }): Promise { const currentConfig = { ...this.config }; // Update the configuration if (changes.headless !== undefined) currentConfig.browser.launchOptions.headless = changes.headless; // Handle device emulation - this overrides individual viewport/userAgent settings if (changes.device) { if (!devices[changes.device]) throw new Error(`Unknown device: ${changes.device}`); const deviceConfig = devices[changes.device]; // Apply all device properties to context options currentConfig.browser.contextOptions = { ...currentConfig.browser.contextOptions, ...deviceConfig, }; } else { // Apply individual settings only if no device is specified if (changes.viewport) currentConfig.browser.contextOptions.viewport = changes.viewport; if (changes.userAgent) currentConfig.browser.contextOptions.userAgent = changes.userAgent; } // Apply additional context options if (changes.geolocation) { currentConfig.browser.contextOptions.geolocation = { latitude: changes.geolocation.latitude, longitude: changes.geolocation.longitude, accuracy: changes.geolocation.accuracy || 100 }; } if (changes.locale) currentConfig.browser.contextOptions.locale = changes.locale; if (changes.timezone) currentConfig.browser.contextOptions.timezoneId = changes.timezone; if (changes.colorScheme) currentConfig.browser.contextOptions.colorScheme = changes.colorScheme; if (changes.permissions) currentConfig.browser.contextOptions.permissions = changes.permissions; // Store the modified config (this as any).config = currentConfig; // Close the current browser context to force recreation with new settings await this.closeBrowserContext(); // Clear tabs since they're attached to the old context this._tabs = []; this._currentTab = undefined; testDebug(`browser config updated for session ${this.sessionId}: headless=${currentConfig.browser.launchOptions.headless}, viewport=${JSON.stringify(currentConfig.browser.contextOptions.viewport)}`); } async stopVideoRecording(): Promise { if (!this._videoRecordingConfig) return []; const videoPaths: string[] = []; // Close all pages to save videos for (const page of this._activePagesWithVideos) { try { if (!page.isClosed()) { await page.close(); const video = page.video(); if (video) { const videoPath = await video.path(); videoPaths.push(videoPath); } } } catch (error) { testDebug('Error closing page for video recording:', error); } } this._activePagesWithVideos.clear(); this._videoRecordingConfig = undefined; this._videoBaseFilename = undefined; return videoPaths; } // Chrome Extension Management async installExtension(extensionPath: string, extensionName: string): Promise { if (this.config.browser.browserName !== 'chromium') throw new Error('Chrome extensions are only supported with Chromium browser.'); // Check if extension is already installed const existingExtension = this._installedExtensions.find(ext => ext.path === extensionPath); if (existingExtension) throw new Error(`Extension is already installed: ${extensionName} (${extensionPath})`); // Read extension manifest to get version info const fs = await import('fs'); const path = await import('path'); const manifestPath = path.join(extensionPath, 'manifest.json'); let version: string | undefined; try { const manifestContent = fs.readFileSync(manifestPath, 'utf8'); const manifest = JSON.parse(manifestContent); version = manifest.version; } catch (error) { testDebug('Could not read extension version:', error); } // Add to installed extensions list this._installedExtensions.push({ path: extensionPath, name: extensionName, version }); testDebug(`Installing Chrome extension: ${extensionName} from ${extensionPath}`); // Restart browser with updated extension list await this._restartBrowserWithExtensions(); } getInstalledExtensions(): Array<{ path: string; name: string; version?: string }> { return [...this._installedExtensions]; } async uninstallExtension(extensionPath: string): Promise<{ path: string; name: string; version?: string } | null> { const extensionIndex = this._installedExtensions.findIndex(ext => ext.path === extensionPath); if (extensionIndex === -1) return null; const removedExtension = this._installedExtensions.splice(extensionIndex, 1)[0]; testDebug(`Uninstalling Chrome extension: ${removedExtension.name} from ${extensionPath}`); // Restart browser with updated extension list await this._restartBrowserWithExtensions(); return removedExtension; } private async _restartBrowserWithExtensions(): Promise { // Close existing browser context if open if (this._browserContextPromise) { const { close } = await this._browserContextPromise; await close(); this._browserContextPromise = undefined; } // Clear all tabs as they will be recreated this._tabs = []; this._currentTab = undefined; testDebug(`Restarting browser with ${this._installedExtensions.length} extensions`); } private _getExtensionPaths(): string[] { return this._installedExtensions.map(ext => ext.path); } // Differential snapshot methods private createSnapshotFingerprint(snapshot: string): string { // Create a lightweight fingerprint of the page structure // Extract key elements: URL, title, main interactive elements, error states const lines = snapshot.split('\n'); const significantLines: string[] = []; for (const line of lines) { if (line.includes('Page URL:') || line.includes('Page Title:') || line.includes('error') || line.includes('Error') || line.includes('button') || line.includes('link') || line.includes('tab') || line.includes('navigation') || line.includes('form') || line.includes('input')) significantLines.push(line.trim()); } return significantLines.join('|').substring(0, 1000); // Limit size } async generateDifferentialSnapshot(): Promise { if (!this.config.differentialSnapshots || !this.currentTab()) return ''; const currentTab = this.currentTabOrDie(); const currentUrl = currentTab.page.url(); const currentTitle = await currentTab.page.title(); const rawSnapshot = await currentTab.captureSnapshot(); const currentFingerprint = this.createSnapshotFingerprint(rawSnapshot); // First time or no previous state if (!this._lastSnapshotFingerprint || !this._lastPageState) { this._lastSnapshotFingerprint = currentFingerprint; this._lastPageState = { url: currentUrl, title: currentTitle }; return `### Page Changes (Differential Mode - First Snapshot)\nāœ“ Initial page state captured\n- URL: ${currentUrl}\n- Title: ${currentTitle}\n\n**šŸ’” Tip: Subsequent operations will show only changes**`; } // Compare with previous state const changes: string[] = []; let hasSignificantChanges = false; if (this._lastPageState.url !== currentUrl) { changes.push(`šŸ“ **URL changed:** ${this._lastPageState.url} → ${currentUrl}`); hasSignificantChanges = true; } if (this._lastPageState.title !== currentTitle) { changes.push(`šŸ“ **Title changed:** "${this._lastPageState.title}" → "${currentTitle}"`); hasSignificantChanges = true; } if (this._lastSnapshotFingerprint !== currentFingerprint) { changes.push(`šŸ”„ **Page structure changed** (DOM elements modified)`); hasSignificantChanges = true; } // Check for console messages or errors const recentConsole = (currentTab as any)._takeRecentConsoleMarkdown?.() || []; if (recentConsole.length > 0) { changes.push(`šŸ” **New console activity** (${recentConsole.length} messages)`); hasSignificantChanges = true; } // Update tracking this._lastSnapshotFingerprint = currentFingerprint; this._lastPageState = { url: currentUrl, title: currentTitle }; if (!hasSignificantChanges) return `### Page Changes (Differential Mode)\nāœ“ **No significant changes detected**\n- Same URL: ${currentUrl}\n- Same title: "${currentTitle}"\n- DOM structure: unchanged\n- Console activity: none\n\n**šŸ’” Tip: Use \`browser_snapshot\` for full page view**`; const result = [ '### Page Changes (Differential Mode)', `šŸ†• **Changes detected:**`, ...changes.map(change => `- ${change}`), '', '**šŸ’” Tip: Use `browser_snapshot` for complete page details**' ]; return result.join('\n'); } resetDifferentialSnapshot(): void { this._lastSnapshotFingerprint = undefined; this._lastPageState = undefined; } }