Implements comprehensive solution for browser_click and other interactive tools returning massive responses (37K+ tokens) due to full page snapshots. Features implemented: 1. **Snapshot size limits** (--max-snapshot-tokens, default 10k) - Automatically truncates large snapshots with helpful messages - Preserves essential info (URL, title, errors) when truncating - Shows exact token counts and configuration suggestions 2. **Optional snapshots** (--no-snapshots) - Disables automatic snapshots after interactive operations - browser_snapshot tool always works for explicit snapshots - Maintains backward compatibility (snapshots enabled by default) 3. **Differential snapshots** (--differential-snapshots) - Shows only changes since last snapshot instead of full page - Tracks URL, title, DOM structure, and console activity - Significantly reduces token usage for incremental operations 4. **Enhanced tool descriptions** - All interactive tools now document snapshot behavior - Clear guidance on when snapshots are included/excluded - Helpful suggestions for users experiencing token limits Configuration options: - CLI: --no-snapshots, --max-snapshot-tokens N, --differential-snapshots - ENV: PLAYWRIGHT_MCP_INCLUDE_SNAPSHOTS, PLAYWRIGHT_MCP_MAX_SNAPSHOT_TOKENS, etc. - Config file: includeSnapshots, maxSnapshotTokens, differentialSnapshots Fixes token overflow errors while providing users full control over snapshot behavior and response sizes. Co-Authored-By: Claude <noreply@anthropic.com>
640 lines
22 KiB
TypeScript
640 lines
22 KiB
TypeScript
/**
|
|
* 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<void> }> | 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<playwright.Page> = new Set();
|
|
private _environmentIntrospector: EnvironmentIntrospector;
|
|
|
|
private static _allContexts: Set<Context> = new Set();
|
|
private _closeBrowserContextPromise: Promise<void> | 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<Tab> {
|
|
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<Tab> {
|
|
const { browserContext } = await this._ensureBrowserContext();
|
|
if (!this._currentTab)
|
|
await browserContext.newPage();
|
|
return this._currentTab!;
|
|
}
|
|
|
|
async listTabsMarkdown(force: boolean = false): Promise<string[]> {
|
|
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<string> {
|
|
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<void> }> {
|
|
if (this._closeBrowserContextPromise)
|
|
throw new Error('Another browser context is being closed.');
|
|
let result: { browserContext: playwright.BrowserContext, close: () => Promise<void> };
|
|
|
|
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<void> }> {
|
|
// 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<void> {
|
|
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<string[]> {
|
|
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<void> {
|
|
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<void> {
|
|
// 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<string> {
|
|
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;
|
|
}
|
|
}
|