/**
* VariableManager - Centralized variable inspection and watch expression management
*
* Handles:
* - Variable inspection state management
* - Watch expression persistence and evaluation
* - Scope chain caching and optimization
* - Variable modification tracking
* - Real-time variable updates during debugging
*/
import { EventEmitter } from "events";
import { ConfigManager } from "../config/ConfigManager.js";
import { ChromeDebugger, VariableDescriptor, ScopeDescriptor, EvaluationResult } from "../debuggers/ChromeDebugger.js";
import { Logger } from "../utils/Logger.js";
export interface WatchExpression {
id: string;
expression: string;
result?: any;
error?: string;
timestamp: Date;
callFrameId?: string;
enabled: boolean;
}
export interface VariableManagerConfig {
maxWatchExpressions: number;
autoEvaluateWatches: boolean;
cacheVariables: boolean;
cacheDurationMs: number;
enableRealTimeUpdates: boolean;
}
export interface VariableCommand {
action: "inspect" | "evaluate" | "modify" | "watch";
callFrameId?: string;
scopeNumber?: number;
variableName?: string;
expression?: string;
newValue?: any;
watchId?: string;
watchAction?: "add" | "remove" | "list" | "clear" | "evaluate";
options?: {
includeNonEnumerable?: boolean;
includeCommandLineAPI?: boolean;
returnByValue?: boolean;
generatePreview?: boolean;
throwOnSideEffect?: boolean;
timeout?: number;
};
}
export interface VariableResult {
success: boolean;
message: string;
data?: any;
variables?: VariableDescriptor[];
scopes?: ScopeDescriptor[];
watchExpressions?: WatchExpression[];
evaluationResult?: EvaluationResult;
callFrameId?: string;
scopeType?: string;
}
export class VariableManager extends EventEmitter {
private logger: Logger;
private configManager: ConfigManager;
private chromeDebugger: ChromeDebugger;
private watchExpressions: Map<string, WatchExpression> = new Map();
private variableCache: Map<string, { variables: VariableDescriptor[], timestamp: Date }> = new Map();
private config: VariableManagerConfig;
private nextWatchId = 1;
constructor(configManager: ConfigManager, chromeDebugger: ChromeDebugger) {
super();
this.logger = new Logger("VariableManager");
this.configManager = configManager;
this.chromeDebugger = chromeDebugger;
// Load configuration
this.config = this.loadConfig();
// Set up event listeners
this.setupEventListeners();
}
/**
* Load configuration for variable manager
*/
private loadConfig(): VariableManagerConfig {
const config = this.configManager.getConfig();
const variablesConfig = (config as any).variables || {};
return {
maxWatchExpressions: variablesConfig.maxWatchExpressions || 50,
autoEvaluateWatches: variablesConfig.autoEvaluateWatches !== false,
cacheVariables: variablesConfig.cacheVariables !== false,
cacheDurationMs: variablesConfig.cacheDurationMs || 5000,
enableRealTimeUpdates: variablesConfig.enableRealTimeUpdates !== false,
...variablesConfig
};
}
/**
* Set up event listeners for debugger events
*/
private setupEventListeners(): void {
// Listen for debugger paused events to auto-evaluate watch expressions
this.chromeDebugger.on('debuggerPaused', (data: any) => {
this.handleDebuggerPaused(data);
});
// Listen for debugger resumed events to clear cache
this.chromeDebugger.on('debuggerResumed', () => {
this.handleDebuggerResumed();
});
// Listen for configuration changes
this.configManager.on('configChanged', () => {
this.config = this.loadConfig();
this.logger.info("Variable manager configuration updated");
});
}
/**
* Handle debugger paused event
*/
private async handleDebuggerPaused(data: any): Promise<void> {
this.logger.info("Debugger paused - evaluating watch expressions");
if (this.config.autoEvaluateWatches) {
await this.evaluateAllWatchExpressions(data.callFrames?.[0]?.callFrameId);
}
// Clear variable cache on new pause
this.clearVariableCache();
this.emit('debuggerPaused', data);
}
/**
* Handle debugger resumed event
*/
private handleDebuggerResumed(): void {
this.logger.info("Debugger resumed - clearing variable cache");
this.clearVariableCache();
this.emit('debuggerResumed');
}
/**
* Execute a variable command
*/
async executeCommand(command: VariableCommand): Promise<VariableResult> {
try {
switch (command.action) {
case "inspect":
return await this.inspectVariables(command);
case "evaluate":
return await this.evaluateExpression(command);
case "modify":
return await this.modifyVariable(command);
case "watch":
return await this.manageWatchExpression(command);
default:
return {
success: false,
message: `Unknown variable action: ${command.action}`
};
}
} catch (error) {
this.logger.error("Failed to execute variable command:", error);
return {
success: false,
message: `Command failed: ${error instanceof Error ? error.message : 'Unknown error'}`
};
}
}
/**
* Inspect variables in a specific scope
*/
private async inspectVariables(command: VariableCommand): Promise<VariableResult> {
if (!this.chromeDebugger.isPausedState()) {
return {
success: false,
message: "Debugger is not paused. Cannot inspect variables."
};
}
const callFrameId = command.callFrameId || this.chromeDebugger.getTopCallFrame()?.callFrameId;
if (!callFrameId) {
return {
success: false,
message: "No call frame available for variable inspection."
};
}
try {
const scopeChain = await this.chromeDebugger.getScopeChain(callFrameId);
const scopeNumber = command.scopeNumber || 0;
if (scopeNumber >= scopeChain.length) {
return {
success: false,
message: `Scope index ${scopeNumber} out of bounds. Available scopes: ${scopeChain.length}`
};
}
const scope = scopeChain[scopeNumber];
if (!scope) {
return {
success: false,
message: `Scope at index ${scopeNumber} not found`
};
}
const cacheKey = `${callFrameId}-${scopeNumber}`;
// Check cache first
if (this.config.cacheVariables && this.isVariableCacheValid(cacheKey)) {
const cached = this.variableCache.get(cacheKey)!;
return {
success: true,
message: "Variables retrieved from cache",
variables: cached.variables,
callFrameId,
scopeType: scope.type
};
}
// Get variables from Chrome DevTools
const variables = await this.chromeDebugger.getVariablesInScope(
scope.objectId,
command.options?.includeNonEnumerable || false
);
// Update cache
if (this.config.cacheVariables) {
this.variableCache.set(cacheKey, {
variables,
timestamp: new Date()
});
}
return {
success: true,
message: `Retrieved ${variables.length} variables from ${scope.type} scope`,
variables,
callFrameId,
scopeType: scope.type
};
} catch (error) {
return {
success: false,
message: `Failed to inspect variables: ${error instanceof Error ? error.message : 'Unknown error'}`
};
}
}
/**
* Evaluate expression in call frame context
*/
private async evaluateExpression(command: VariableCommand): Promise<VariableResult> {
if (!command.expression) {
return {
success: false,
message: "Expression is required for evaluation"
};
}
try {
let result: EvaluationResult;
if (command.callFrameId && this.chromeDebugger.isPausedState()) {
// Evaluate in call frame context
result = await this.chromeDebugger.evaluateOnCallFrame(
command.callFrameId,
command.expression,
command.options || {}
);
} else {
// Evaluate in global context
result = await this.chromeDebugger.evaluateExpression(
command.expression,
command.options || {}
);
}
return {
success: !result.wasThrown,
message: result.wasThrown ? "Expression evaluation threw an exception" : "Expression evaluated successfully",
evaluationResult: result,
callFrameId: command.callFrameId
};
} catch (error) {
return {
success: false,
message: `Failed to evaluate expression: ${error instanceof Error ? error.message : 'Unknown error'}`
};
}
}
/**
* Modify variable value
*/
private async modifyVariable(command: VariableCommand): Promise<VariableResult> {
if (!this.chromeDebugger.isPausedState()) {
return {
success: false,
message: "Debugger is not paused. Cannot modify variables."
};
}
if (!command.callFrameId || command.scopeNumber === undefined || !command.variableName || command.newValue === undefined) {
return {
success: false,
message: "callFrameId, scopeNumber, variableName, and newValue are required for variable modification"
};
}
try {
await this.chromeDebugger.setVariableValue(
command.callFrameId,
command.scopeNumber,
command.variableName,
command.newValue
);
// Clear cache for this scope
const cacheKey = `${command.callFrameId}-${command.scopeNumber}`;
this.variableCache.delete(cacheKey);
return {
success: true,
message: `Variable ${command.variableName} modified successfully`,
callFrameId: command.callFrameId
};
} catch (error) {
return {
success: false,
message: `Failed to modify variable: ${error instanceof Error ? error.message : 'Unknown error'}`
};
}
}
/**
* Check if variable cache is valid
*/
private isVariableCacheValid(cacheKey: string): boolean {
const cached = this.variableCache.get(cacheKey);
if (!cached) return false;
const age = Date.now() - cached.timestamp.getTime();
return age < this.config.cacheDurationMs;
}
/**
* Clear variable cache
*/
private clearVariableCache(): void {
this.variableCache.clear();
this.logger.debug("Variable cache cleared");
}
/**
* Manage watch expressions
*/
private async manageWatchExpression(command: VariableCommand): Promise<VariableResult> {
// For watch commands, we'll use a separate action field in the command
// This will be set by the MCP tool based on the input
const action = command.watchAction || 'list';
switch (action) {
case 'add':
return await this.addWatchExpression(command.expression!);
case 'remove':
return this.removeWatchExpression(command.watchId!);
case 'list':
return this.listWatchExpressions();
case 'clear':
return this.clearWatchExpressions();
case 'evaluate':
return await this.evaluateWatchExpressions(command.callFrameId);
default:
return {
success: false,
message: `Unknown watch expression action: ${action}`
};
}
}
/**
* Add a new watch expression
*/
private async addWatchExpression(expression: string): Promise<VariableResult> {
if (!expression) {
return {
success: false,
message: "Expression is required for watch"
};
}
if (this.watchExpressions.size >= this.config.maxWatchExpressions) {
return {
success: false,
message: `Maximum watch expressions limit reached (${this.config.maxWatchExpressions})`
};
}
const watchId = `watch_${this.nextWatchId++}`;
const watchExpression: WatchExpression = {
id: watchId,
expression,
timestamp: new Date(),
enabled: true
};
this.watchExpressions.set(watchId, watchExpression);
// Try to evaluate immediately if debugger is paused
if (this.chromeDebugger.isPausedState()) {
await this.evaluateWatchExpression(watchExpression);
}
this.logger.info(`Added watch expression: ${expression}`);
this.emit('watchAdded', watchExpression);
return {
success: true,
message: `Watch expression added: ${expression}`,
watchExpressions: [watchExpression]
};
}
/**
* Remove a watch expression
*/
private removeWatchExpression(watchId: string): VariableResult {
const watchExpression = this.watchExpressions.get(watchId);
if (!watchExpression) {
return {
success: false,
message: `Watch expression not found: ${watchId}`
};
}
this.watchExpressions.delete(watchId);
this.logger.info(`Removed watch expression: ${watchExpression.expression}`);
this.emit('watchRemoved', watchExpression);
return {
success: true,
message: `Watch expression removed: ${watchExpression.expression}`
};
}
/**
* List all watch expressions
*/
private listWatchExpressions(): VariableResult {
const watchExpressions = Array.from(this.watchExpressions.values());
return {
success: true,
message: `Found ${watchExpressions.length} watch expressions`,
watchExpressions
};
}
/**
* Clear all watch expressions
*/
private clearWatchExpressions(): VariableResult {
const count = this.watchExpressions.size;
this.watchExpressions.clear();
this.logger.info(`Cleared ${count} watch expressions`);
this.emit('watchesCleared', count);
return {
success: true,
message: `Cleared ${count} watch expressions`
};
}
/**
* Evaluate all watch expressions
*/
private async evaluateWatchExpressions(callFrameId?: string): Promise<VariableResult> {
const watchExpressions = Array.from(this.watchExpressions.values());
const results: WatchExpression[] = [];
for (const watch of watchExpressions) {
if (watch.enabled) {
await this.evaluateWatchExpression(watch, callFrameId);
}
results.push(watch);
}
return {
success: true,
message: `Evaluated ${results.length} watch expressions`,
watchExpressions: results
};
}
/**
* Evaluate all watch expressions (internal method)
*/
private async evaluateAllWatchExpressions(callFrameId?: string): Promise<void> {
const watchExpressions = Array.from(this.watchExpressions.values());
for (const watch of watchExpressions) {
if (watch.enabled) {
await this.evaluateWatchExpression(watch, callFrameId);
}
}
if (watchExpressions.length > 0) {
this.emit('watchesEvaluated', watchExpressions);
}
}
/**
* Evaluate a single watch expression
*/
private async evaluateWatchExpression(watch: WatchExpression, callFrameId?: string): Promise<void> {
try {
let result: EvaluationResult;
if (callFrameId && this.chromeDebugger.isPausedState()) {
result = await this.chromeDebugger.evaluateOnCallFrame(callFrameId, watch.expression);
} else {
result = await this.chromeDebugger.evaluateExpression(watch.expression);
}
watch.result = result.result;
watch.error = result.wasThrown ? result.exceptionDetails?.text : undefined;
watch.callFrameId = callFrameId;
watch.timestamp = new Date();
} catch (error) {
watch.result = undefined;
watch.error = error instanceof Error ? error.message : 'Unknown error';
watch.timestamp = new Date();
}
}
/**
* Get watch expression by ID
*/
getWatchExpression(watchId: string): WatchExpression | undefined {
return this.watchExpressions.get(watchId);
}
/**
* Get all watch expressions
*/
getAllWatchExpressions(): WatchExpression[] {
return Array.from(this.watchExpressions.values());
}
/**
* Get current configuration
*/
getConfig(): VariableManagerConfig {
return { ...this.config };
}
/**
* Get cache statistics
*/
getCacheStats(): { size: number; keys: string[] } {
return {
size: this.variableCache.size,
keys: Array.from(this.variableCache.keys())
};
}
}