/**
* VSCode Automation MCP Server - VSCode Driver
*
* This module wraps vscode-extension-tester (ExTester) to provide
* programmatic control over VSCode for automation and testing.
*
* @author Sukarth Acharya
* @license MIT
*/
// ============================================================
// CRITICAL: Patch selenium-webdriver/chrome BEFORE importing vscode-extension-tester
// This must happen before any imports that might load the chrome module.
// ============================================================
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
// Patch both Chrome Options and ServiceBuilder for better error handling
const chromeModule = require('selenium-webdriver/chrome');
const OptionsClass = chromeModule.Options;
const ServiceBuilderClass = chromeModule.ServiceBuilder;
const originalAddArguments = OptionsClass.prototype.addArguments;
const originalServiceBuild = ServiceBuilderClass.prototype.build;
const injectedSymbol = Symbol('gpuArgsInjected');
// Patch ServiceBuilder to enable verbose logging for debugging
ServiceBuilderClass.prototype.build = function () {
console.log('ServiceBuilder.build() called - enabling verbose logging');
// Enable verbose logging to see what's happening with ChromeDriver
this.loggingTo?.(process.env.TEMP ? `${process.env.TEMP}\\chromedriver.log` : '/tmp/chromedriver.log');
this.enableVerboseLogging?.();
return originalServiceBuild.call(this);
};
OptionsClass.prototype.addArguments = function (...args: string[]) {
// Inject GPU-disabling args the first time addArguments is called
if (!(this as any)[injectedSymbol]) {
(this as any)[injectedSymbol] = true;
console.log('Injecting GPU-disabling arguments into Chrome Options');
// Add Electron-compatible flags to disable GPU and prevent crashes
// These are the flags that work in Electron (ChromeDriver driving VSCode)
const gpuDisablingFlags = [
// Core GPU disable - this is the CRITICAL one
'--disable-gpu',
'--disable-gpu-compositing',
'--disable-gpu-rasterization',
// Sandbox and security
'--no-sandbox',
'--disable-setuid-sandbox',
// Prevent GPU process crashes
'--disable-software-rasterizer',
'--disable-extensions',
// Startup options
'--no-first-run',
'--no-default-browser-check',
// Disable features that might trigger GPU
'--disable-partial-raster',
'--disable-plugins',
'--disable-preconnect',
'--disable-sync',
// Media options
'--disable-media-session-api',
];
console.log(`Injecting ${gpuDisablingFlags.length} GPU-disabling flags`);
originalAddArguments.call(this, ...gpuDisablingFlags);
}
// Call original with the provided args
return originalAddArguments.call(this, ...args);
};
console.log('Patched Chrome Options to disable GPU at module load time');
// Now import vscode-extension-tester (it will use our patched Options)
import {
VSBrowser,
WebDriver,
Workbench,
EditorView,
TextEditor,
ActivityBar,
SideBarView,
BottomBarPanel,
ProblemsView,
MarkerType,
InputBox,
QuickOpenBox,
WebView,
By,
until,
WebElement,
ExTester,
ReleaseQuality,
logging,
} from 'vscode-extension-tester';
import * as path from 'node:path';
import * as fs from 'node:fs';
import * as os from 'node:os';
import type {
VSCodeDriverConfig,
DriverState,
ToolResult,
ScreenshotResult,
ElementInfo,
DiagnosticMessage,
EditorInfo,
CommandInfo,
ElementSelector,
ClickOptions,
TypeOptions,
OpenFileOptions,
} from './types.js';
/**
* Default configuration for the VSCode driver
* Environment variables can override these defaults:
* - VSCODE_AUTOMATION_STORAGE_PATH: Path to store VSCode and ChromeDriver
* - VSCODE_AUTOMATION_VERSION: VSCode version to use (e.g., "1.95.0", "1.85.0", or "latest")
* - VSCODE_AUTOMATION_OFFLINE: Set to "true" to use cached binaries only
*/
const DEFAULT_CONFIG: VSCodeDriverConfig = {
storagePath: process.env.VSCODE_AUTOMATION_STORAGE_PATH || path.join(os.tmpdir(), 'vscode-automation-mcp'),
vscodeVersion: process.env.VSCODE_AUTOMATION_VERSION || 'latest',
logLevel: process.env.VSCODE_AUTOMATION_LOG_LEVEL || 'info',
offline: process.env.VSCODE_AUTOMATION_OFFLINE === 'true',
};
/**
* VSCode Driver class that manages the ExTester instance and provides
* high-level methods for VSCode automation.
*/
export class VSCodeDriver {
private static instance: VSCodeDriver | null = null;
private config: VSCodeDriverConfig;
private state: DriverState = 'idle';
private driver: WebDriver | null = null;
private browser: VSBrowser | null = null;
private exTester: ExTester | null = null;
private workbench: Workbench | null = null;
private initPromise: Promise<void> | null = null;
private screenshotDir: string;
private constructor(config: Partial<VSCodeDriverConfig> = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
this.screenshotDir = path.join(this.config.storagePath!, 'screenshots');
// Ensure directories exist
if (!fs.existsSync(this.config.storagePath!)) {
fs.mkdirSync(this.config.storagePath!, { recursive: true });
}
if (!fs.existsSync(this.screenshotDir)) {
fs.mkdirSync(this.screenshotDir, { recursive: true });
}
}
/**
* Get the singleton instance of VSCodeDriver
*/
public static getInstance(config?: Partial<VSCodeDriverConfig>): VSCodeDriver {
if (!VSCodeDriver.instance) {
VSCodeDriver.instance = new VSCodeDriver(config);
}
return VSCodeDriver.instance;
}
/**
* Reset the singleton instance (for testing purposes)
*/
public static resetInstance(): void {
if (VSCodeDriver.instance) {
VSCodeDriver.instance.dispose().catch(console.error);
VSCodeDriver.instance = null;
}
}
/**
* Get the current state of the driver
*/
public getState(): DriverState {
return this.state;
}
/**
* Check if the driver is ready for operations
*/
public isReady(): boolean {
return this.state === 'ready' && this.driver !== null;
}
/**
* Initialize the VSCode instance and WebDriver
*/
public async initialize(): Promise<ToolResult<void>> {
// If already initializing, wait for that to complete
if (this.initPromise) {
await this.initPromise;
return { success: true, message: 'VSCode already initialized' };
}
// If already ready, return success
if (this.state === 'ready') {
return { success: true, message: 'VSCode already initialized' };
}
// If previously disposed or errored, allow re-initialization
if (this.state === 'disposed' || this.state === 'error') {
console.log(`Re-initializing VSCode driver from state: ${this.state}`);
// Clean up any remaining state
this.driver = null;
this.browser = null;
this.workbench = null;
this.exTester = null;
}
this.state = 'initializing';
this.initPromise = this.doInitialize();
try {
await this.initPromise;
this.state = 'ready';
return { success: true, message: 'VSCode initialized successfully' };
} catch (error) {
this.state = 'error';
const errorMessage = error instanceof Error ? error.message : String(error);
return { success: false, error: `Failed to initialize VSCode: ${errorMessage}` };
} finally {
this.initPromise = null;
}
}
private async doInitialize(): Promise<void> {
try {
// Create ExTester instance for downloading VSCode and ChromeDriver
this.exTester = new ExTester(
this.config.storagePath,
ReleaseQuality.Stable,
this.config.extensionsDir
);
console.log('Downloading VSCode and ChromeDriver...');
// Download VSCode (without packaging an extension)
await this.exTester.downloadCode(this.config.vscodeVersion);
// Download ChromeDriver for the downloaded VSCode version
await this.exTester.downloadChromeDriver(this.config.vscodeVersion);
console.log('VSCode and ChromeDriver downloaded successfully');
// Compute the executable path based on OS and storage location
const executablePath = this.getVSCodeExecutablePath();
if (!fs.existsSync(executablePath)) {
throw new Error(`VSCode executable not found at: ${executablePath}`);
}
console.log(`VSCode executable found at: ${executablePath}`);
// CRITICAL: Delete the 'data' folder if it exists in the VSCode directory.
// When this folder exists, VSCode enters "portable mode" and ignores
// the --user-data-dir argument, which causes DevToolsActivePort to be
// created in the wrong location and ChromeDriver to time out.
const vscodeDir = path.dirname(executablePath);
const portableDataDir = path.join(vscodeDir, 'data');
if (fs.existsSync(portableDataDir)) {
console.log(`Removing portable data directory to prevent VSCode from entering portable mode: ${portableDataDir}`);
fs.rmSync(portableDataDir, { recursive: true, force: true });
}
// Read the actual VSCode version from product.json
const codeVersion = this.getInstalledVSCodeVersion();
console.log(`VSCode version: ${codeVersion}`);
// Set environment variables that VSBrowser needs
process.env.TEST_RESOURCES = this.config.storagePath;
if (this.config.extensionsDir) {
process.env.EXTENSIONS_FOLDER = this.config.extensionsDir;
}
// Remove NODE_* environment variables that can cause VSCode to spam messages or misbehave
// See https://github.com/microsoft/vscode/issues/204005
for (const key of Object.keys(process.env)) {
if (key.startsWith('NODE_')) {
delete process.env[key];
}
}
// CRITICAL: Remove ALL VSCODE_*, ELECTRON_*, and CHROME_* environment variables
// inherited from parent VSCode. These can cause the child VSCode instance to crash
// or behave incorrectly when launched as a child process of another VSCode instance.
// We'll set the ones we need explicitly after this cleanup.
const prefixesToRemove = ['VSCODE_', 'ELECTRON_', 'CHROME_'];
for (const key of Object.keys(process.env)) {
for (const prefix of prefixesToRemove) {
if (key.startsWith(prefix)) {
console.log(`Removing inherited env var: ${key}`);
delete process.env[key];
break;
}
}
}
// Also remove these specific variables that can cause issues
const specificVarsToRemove = [
'ORIGINAL_XDG_CURRENT_DESKTOP',
'GDK_BACKEND',
];
for (const varName of specificVarsToRemove) {
if (process.env[varName] !== undefined) {
console.log(`Removing inherited env var: ${varName}`);
delete process.env[varName];
}
}
// Force VSCode to use a unique IPC handle to avoid conflicts with user's main VSCode
// This prevents the test instance from connecting to the user's VSCode server
process.env.VSCODE_IPC_HOOK = path.join(this.config.storagePath!, 'vscode-ipc.sock');
// ========================================================
// GPU DISABLE SETUP
// ========================================================
// Note: GPU-disabling flags are injected via Chrome Options patch at module load time.
// We do NOT use portable mode or argv.json because it conflicts with ChromeDriver's
// --user-data-dir which is critical for DevToolsActivePort file creation.
//
// The Chrome Options patch injects: --disable-gpu, --disable-gpu-compositing,
// --disable-gpu-rasterization, --no-sandbox, etc. directly to the command line.
// ========================================================
// CRITICAL: Set a unique IPC hook to prevent conflicts with existing VSCode instances
// This creates a separate communication channel for the test instance
const uniqueId = Date.now().toString(36) + Math.random().toString(36).substring(2);
process.env.VSCODE_IPC_HOOK = path.join(this.config.storagePath!, `vscode-ipc-${uniqueId}.sock`);
console.log(`Set VSCODE_IPC_HOOK to: ${process.env.VSCODE_IPC_HOOK}`);
// NOTE: We do NOT set VSCODE_APPDATA or VSCODE_PORTABLE because ChromeDriver
// passes --user-data-dir and that's where DevToolsActivePort file will be created.
// Any conflicting user data directories will cause ChromeDriver to time out.
// Also set these environment variables as additional GPU-related hints
process.env.ELECTRON_DISABLE_GPU = '1';
process.env.ELECTRON_NO_ATTACH_CONSOLE = '1';
process.env.LIBGL_ALWAYS_SOFTWARE = '1';
// Additional GPU-disabling environment variables for Chromium
process.env.CHROME_DISABLE_GPU = '1';
process.env.CHROME_DISABLE_GPU_COMPOSITING = '1';
process.env.DISABLE_GPU = '1';
// These help with Chromium on Windows
process.env.CHROME_DCHECK_ALWAYS_ON = '0';
console.log('Environment variables set:');
console.log(` ELECTRON_DISABLE_GPU=${process.env.ELECTRON_DISABLE_GPU}`);
console.log(` CHROME_DISABLE_GPU=${process.env.CHROME_DISABLE_GPU}`);
console.log(` VSCODE_PORTABLE=${process.env.VSCODE_PORTABLE}`);
// Wait for file system to sync
await new Promise(resolve => setTimeout(resolve, 200));
// Kill any existing test VSCode processes that might interfere
await this.killExistingTestVSCodeProcesses(executablePath);
// Give some time for cleanup
await new Promise(resolve => setTimeout(resolve, 500));
// Create and start the VSBrowser
console.log('Starting VSCode browser...');
const logLevel = this.config.logLevel === 'debug' ? logging.Level.DEBUG : logging.Level.INFO;
// Custom settings to avoid conflicts with existing VSCode instances
// and prevent multiple windows from opening
const customSettings = {
'workbench.editor.enablePreview': false,
'workbench.startupEditor': 'none',
'window.titleBarStyle': 'custom',
'window.commandCenter': false,
'window.dialogStyle': 'custom',
'window.restoreFullscreen': false,
'window.newWindowDimensions': 'default',
'window.restoreWindows': 'none', // Don't restore previous windows
'window.openFoldersInNewWindow': 'off', // Don't open folders in new window
'window.openFilesInNewWindow': 'off', // Don't open files in new window
'security.workspace.trust.enabled': false,
'files.simpleDialog.enable': true,
'terminal.integrated.copyOnSelection': true,
'update.mode': 'none', // Disable update checks
'extensions.autoUpdate': false, // Disable extension auto-update
'telemetry.telemetryLevel': 'off', // Disable telemetry
'workbench.secondarySideBar.defaultVisibility': 'hidden', // Match VSBrowser defaults
// GPU-related settings to help prevent crashes
'gpu-sandbox': false,
};
this.browser = new VSBrowser(codeVersion, ReleaseQuality.Stable, customSettings, logLevel);
// Try to start with retry logic for transient failures
let lastError: Error | null = null;
const maxRetries = 2;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
console.log(`Starting VSCode browser (attempt ${attempt}/${maxRetries})...`);
await this.browser.start(executablePath);
console.log('VSCode browser started successfully');
break;
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
console.error(`Attempt ${attempt} failed: ${lastError.message}`);
if (attempt < maxRetries) {
console.log(`Waiting before retry...`);
await new Promise(resolve => setTimeout(resolve, 1000));
// Try to create a fresh browser instance for the next attempt
this.browser = new VSBrowser(codeVersion, ReleaseQuality.Stable, customSettings, logLevel);
}
}
}
if (lastError) {
throw lastError;
}
// Wait for the workbench to be fully loaded
console.log('Waiting for workbench to load...');
await this.browser.waitForWorkbench();
// Get the driver and workbench references
this.driver = this.browser.driver;
this.workbench = new Workbench();
console.log('VSCode initialized successfully!');
} catch (error) {
console.error('Failed to initialize VSCode driver:', error);
throw error;
}
}
/**
* Kill any existing test VSCode processes to prevent conflicts
*/
private async killExistingTestVSCodeProcesses(executablePath: string): Promise<void> {
try {
if (process.platform === 'win32') {
// On Windows, kill Code.exe processes that match our test executable path
const { execSync } = await import('child_process');
try {
// Use WMIC to find and kill processes with the specific path
const normalizedPath = executablePath.replace(/\\/g, '\\\\');
execSync(`wmic process where "ExecutablePath like '%${path.basename(path.dirname(normalizedPath))}%'" delete`, {
stdio: 'ignore',
timeout: 5000
});
} catch {
// Ignore errors - process might not exist
}
} else {
// On Unix-like systems, use pkill
const { execSync } = await import('child_process');
try {
execSync(`pkill -f "${executablePath}"`, { stdio: 'ignore', timeout: 5000 });
} catch {
// Ignore errors - process might not exist
}
}
} catch (error) {
console.log('Note: Could not kill existing test VSCode processes:', error);
}
}
/**
* Create a launcher wrapper script that adds --disable-gpu flag to VSCode
* This is needed because vscode-extension-tester doesn't support custom Chrome args
* and the GPU process crashes on some Windows systems
*/
private async createLauncherWrapper(executablePath: string): Promise<string> {
if (process.platform !== 'win32') {
// On non-Windows, we can use the executable directly
// (GPU issues are mainly a Windows problem)
return executablePath;
}
const wrapperPath = path.join(this.config.storagePath!, 'code-launcher.cmd');
// Create a batch script that launches Code.exe with --disable-gpu
// The %* passes all arguments from ChromeDriver to VSCode
const wrapperContent = `@echo off
"${executablePath}" --disable-gpu --disable-gpu-compositing --disable-software-rasterizer %*
`;
fs.writeFileSync(wrapperPath, wrapperContent, { encoding: 'utf8' });
console.log(`Created launcher wrapper at: ${wrapperPath}`);
return wrapperPath;
}
/**
* Get the installed VSCode version from product.json
*/
private getInstalledVSCodeVersion(): string {
try {
const productJsonPath = this.getProductJsonPath();
if (fs.existsSync(productJsonPath)) {
const productJson = JSON.parse(fs.readFileSync(productJsonPath, 'utf8'));
return productJson.version || '1.95.0';
}
} catch (error) {
console.warn('Could not read VSCode version from product.json:', error);
}
return this.config.vscodeVersion === 'latest' ? '1.95.0' : this.config.vscodeVersion!;
}
/**
* Get the path to VSCode's product.json
*/
private getProductJsonPath(): string {
const storagePath = this.config.storagePath!;
const platform = process.platform;
const arch = process.arch;
let codeFolder: string;
if (platform === 'darwin') {
codeFolder = path.join(storagePath, 'Visual Studio Code.app');
return path.join(codeFolder, 'Contents', 'Resources', 'app', 'product.json');
} else if (platform === 'win32') {
const downloadPlatform = arch === 'arm64' ? 'win32-arm64-archive' : 'win32-x64-archive';
codeFolder = path.join(storagePath, `VSCode-${downloadPlatform}`);
} else {
const downloadPlatform = `linux-${arch === 'ia32' ? 'ia32' : arch}`;
codeFolder = path.join(storagePath, `VSCode-${downloadPlatform}`);
}
return path.join(codeFolder, 'resources', 'app', 'product.json');
}
/**
* Get the VSCode executable path based on OS and release type
*/
private getVSCodeExecutablePath(): string {
const storagePath = this.config.storagePath!;
const platform = process.platform;
// Compute the download platform string
let downloadPlatform: string;
const arch = process.arch;
if (platform === 'linux') {
downloadPlatform = `linux-${arch === 'ia32' ? 'ia32' : arch}`;
} else if (platform === 'win32') {
switch (arch) {
case 'arm64':
downloadPlatform = 'win32-arm64-archive';
break;
case 'x64':
downloadPlatform = 'win32-x64-archive';
break;
default:
throw new Error(`Unsupported Windows architecture: ${arch}`);
}
} else if (platform === 'darwin') {
downloadPlatform = `darwin-${arch}`;
} else {
throw new Error(`Unsupported platform: ${platform}`);
}
// Compute the code folder
const codeFolder = platform === 'darwin'
? path.join(storagePath, 'Visual Studio Code.app')
: path.join(storagePath, `VSCode-${downloadPlatform}`);
// Compute the executable path
switch (platform) {
case 'darwin':
return path.join(codeFolder, 'Contents', 'MacOS', 'Electron');
case 'win32':
return path.join(codeFolder, 'Code.exe');
case 'linux':
return path.join(codeFolder, 'code');
default:
throw new Error(`Unsupported platform: ${platform}`);
}
}
/**
* Ensure driver is initialized before performing operations
*/
private async ensureInitialized(): Promise<void> {
if (!this.isReady()) {
const result = await this.initialize();
if (!result.success) {
throw new Error(result.error || 'Failed to initialize VSCode');
}
}
}
/**
* Get the WebDriver instance
*/
public async getDriver(): Promise<WebDriver> {
await this.ensureInitialized();
if (!this.driver) {
throw new Error('WebDriver not available');
}
return this.driver;
}
/**
* Get the Workbench instance
*/
public async getWorkbench(): Promise<Workbench> {
await this.ensureInitialized();
if (!this.workbench) {
this.workbench = new Workbench();
}
return this.workbench;
}
/**
* Execute a VSCode command
*/
public async executeCommand(commandId: string, ...args: unknown[]): Promise<ToolResult<void>> {
try {
const workbench = await this.getWorkbench();
// Open command palette and execute command
const input = await workbench.openCommandPrompt() as QuickOpenBox;
await input.setText(`>${commandId}`);
await input.confirm();
// Wait a bit for the command to execute
await this.driver?.sleep(500);
return {
success: true,
message: `Command '${commandId}' executed successfully`
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
success: false,
error: `Failed to execute command '${commandId}': ${errorMessage}`
};
}
}
/**
* Click a UI element by selector
*/
public async clickElement(
selector: ElementSelector,
options: ClickOptions = {}
): Promise<ToolResult<void>> {
try {
const driver = await this.getDriver();
const element = await this.findElement(selector);
if (!element) {
return {
success: false,
error: `Element not found with selector: ${selector.value}`
};
}
// Scroll element into view
await driver.executeScript('arguments[0].scrollIntoView(true);', element);
await driver.sleep(100);
if (options.doubleClick) {
const actions = driver.actions();
await actions.doubleClick(element).perform();
} else if (options.rightClick) {
const actions = driver.actions();
await actions.contextClick(element).perform();
} else {
await element.click();
}
return {
success: true,
message: 'Element clicked successfully'
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
success: false,
error: `Failed to click element: ${errorMessage}`
};
}
}
/**
* Type text into the focused element or a specific element.
* For text editors, uses clipboard-based insertion which is more stable.
* For other elements, uses sendKeys.
*/
public async typeText(
text: string,
selector?: ElementSelector,
options: TypeOptions = {}
): Promise<ToolResult<void>> {
try {
const driver = await this.getDriver();
// If a specific selector is provided, use that element
if (selector) {
const element = await this.findElement(selector);
if (!element) {
return {
success: false,
error: `Element not found with selector: ${selector.value}`
};
}
await element.click();
if (options.clear) {
await element.clear();
}
await element.sendKeys(text);
if (options.pressEnter) {
const Key = (await import('selenium-webdriver')).Key;
await element.sendKeys(Key.ENTER);
}
return {
success: true,
message: 'Text typed successfully'
};
}
// No selector - try to type into the active text editor using TextEditor API
// This is more stable than sendKeys which can crash VSCode
try {
const editorView = new EditorView();
const activeTab = await editorView.getActiveTab();
if (activeTab) {
const title = await activeTab.getTitle();
const editor = await editorView.openEditor(title);
if (editor instanceof TextEditor) {
if (options.clear) {
await editor.clearText();
}
// Use setText which uses clipboard - more stable than sendKeys
// For appending, we need to get current text first
if (!options.clear) {
const currentText = await editor.getText();
await editor.setText(currentText + text);
} else {
await editor.setText(text);
}
if (options.pressEnter) {
// Add a newline since we can't use sendKeys safely
const content = await editor.getText();
await editor.setText(content + '\n');
}
return {
success: true,
message: 'Text typed successfully via TextEditor API'
};
}
}
} catch (editorError) {
console.log('Could not use TextEditor API, falling back to active element:', editorError);
}
// Fallback: try to get active element and use sendKeys
// This might crash on some systems, but it's our last resort
try {
const activeElement = await driver.switchTo().activeElement();
if (activeElement) {
if (options.clear) {
await activeElement.clear();
}
await activeElement.sendKeys(text);
if (options.pressEnter) {
const Key = (await import('selenium-webdriver')).Key;
await activeElement.sendKeys(Key.ENTER);
}
return {
success: true,
message: 'Text typed successfully via active element'
};
}
} catch (activeError) {
console.log('Could not use active element:', activeError);
}
return {
success: false,
error: 'No target element found for typing. Try providing a selector or opening a file first.'
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
success: false,
error: `Failed to type text: ${errorMessage}`
};
}
}
/**
* Open a file in the editor
*/
public async openFile(
filePath: string,
options: OpenFileOptions = {}
): Promise<ToolResult<EditorInfo>> {
try {
const workbench = await this.getWorkbench();
// Use command palette to open file
const input = await workbench.openCommandPrompt() as QuickOpenBox;
await input.setText(filePath);
await input.confirm();
await this.driver?.sleep(1000); // Wait for file to open
// Navigate to line/column if specified
if (options.line) {
const gotoInput = await workbench.openCommandPrompt() as QuickOpenBox;
const lineCol = options.column
? `:${options.line}:${options.column}`
: `:${options.line}`;
await gotoInput.setText(lineCol);
await gotoInput.confirm();
await this.driver?.sleep(300);
}
// Get editor info
const editorView = new EditorView();
const editor = await editorView.getActiveTab();
if (!editor) {
return {
success: false,
error: 'No active editor after opening file'
};
}
const title = await editor.getTitle();
const editorInfo: EditorInfo = {
fileName: title,
filePath: filePath,
isDirty: false, // Will be determined if we open as TextEditor
lineCount: 0, // Will be updated if we can get the text editor
};
return {
success: true,
data: editorInfo,
message: `File '${filePath}' opened successfully`
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
success: false,
error: `Failed to open file: ${errorMessage}`
};
}
}
/**
* Take a screenshot of the VSCode window
* Compresses image to under 5MB for AI model compatibility
*/
public async takeScreenshot(filename?: string): Promise<ToolResult<ScreenshotResult>> {
try {
const driver = await this.getDriver();
const screenshot = await driver.takeScreenshot();
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const screenshotFilename = filename || `screenshot-${timestamp}.png`;
const screenshotPath = path.join(this.screenshotDir, screenshotFilename);
// Decode the base64 screenshot
const screenshotBuffer = Buffer.from(screenshot, 'base64');
// Check size and compress if needed (target < 5MB = 5 * 1024 * 1024 bytes)
const MAX_SIZE = 5 * 1024 * 1024;
let finalBase64 = screenshot;
let mimeType = 'image/png';
let wasCompressed = false;
if (screenshotBuffer.length > MAX_SIZE) {
console.log(`Screenshot size ${(screenshotBuffer.length / 1024 / 1024).toFixed(2)}MB exceeds 5MB, compressing...`);
try {
const sharp = (await import('sharp')).default;
// Try progressive quality reduction
let quality = 80;
let compressedBuffer: Buffer = screenshotBuffer;
while (compressedBuffer.length > MAX_SIZE && quality >= 20) {
const result = await sharp(screenshotBuffer)
.jpeg({ quality, mozjpeg: true })
.toBuffer();
compressedBuffer = Buffer.from(result);
console.log(`Compressed to ${(compressedBuffer.length / 1024 / 1024).toFixed(2)}MB at quality ${quality}`);
quality -= 10;
}
// If still too large, resize
if (compressedBuffer.length > MAX_SIZE) {
const metadata = await sharp(screenshotBuffer).metadata();
const scale = Math.sqrt(MAX_SIZE / compressedBuffer.length) * 0.9;
const newWidth = Math.floor((metadata.width || 1920) * scale);
const result = await sharp(screenshotBuffer)
.resize(newWidth)
.jpeg({ quality: 70, mozjpeg: true })
.toBuffer();
compressedBuffer = Buffer.from(result);
console.log(`Resized and compressed to ${(compressedBuffer.length / 1024 / 1024).toFixed(2)}MB`);
}
finalBase64 = compressedBuffer.toString('base64');
mimeType = 'image/jpeg';
wasCompressed = true;
} catch (sharpError) {
console.error('Sharp compression failed, using original:', sharpError);
// Fall back to original if sharp fails
}
}
// Save screenshot to file (save original PNG for quality)
fs.writeFileSync(screenshotPath, screenshotBuffer);
return {
success: true,
data: {
base64: finalBase64,
filePath: screenshotPath,
mimeType: mimeType,
},
message: wasCompressed
? `Screenshot saved to ${screenshotPath} (compressed to JPEG for transfer)`
: `Screenshot saved to ${screenshotPath}`
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
success: false,
error: `Failed to take screenshot: ${errorMessage}`
};
}
}
/**
* Get information about a UI element
*/
public async getElement(selector: ElementSelector): Promise<ToolResult<ElementInfo>> {
try {
const element = await this.findElement(selector);
if (!element) {
return {
success: false,
error: `Element not found with selector: ${selector.value}`
};
}
// Use getRect() which returns combined location + size (replaces deprecated getLocation)
const rect = await element.getRect();
const info: ElementInfo = {
tagName: await element.getTagName(),
text: await element.getText(),
isDisplayed: await element.isDisplayed(),
isEnabled: await element.isEnabled(),
isSelected: await element.isSelected(),
location: { x: rect.x, y: rect.y },
size: { width: rect.width, height: rect.height },
attributes: {},
cssClasses: [],
};
// Get class attribute
const classAttr = await element.getAttribute('class');
if (classAttr) {
info.cssClasses = classAttr.split(' ').filter(c => c.length > 0);
}
// Get common attributes
const commonAttrs = ['id', 'name', 'role', 'aria-label', 'title', 'href', 'src', 'value'];
for (const attr of commonAttrs) {
const value = await element.getAttribute(attr);
if (value) {
info.attributes[attr] = value;
}
}
return {
success: true,
data: info
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
success: false,
error: `Failed to get element info: ${errorMessage}`
};
}
}
/**
* Get diagnostics from the Problems panel
*/
public async getDiagnostics(): Promise<ToolResult<DiagnosticMessage[]>> {
try {
const bottomBar = new BottomBarPanel();
await bottomBar.toggle(true);
const problemsView = await bottomBar.openProblemsView();
const diagnostics: DiagnosticMessage[] = [];
// Get all marker types (Info is not available in MarkerType, only Error and Warning)
const markerTypes = [
{ type: MarkerType.Error, severity: 'error' as const },
{ type: MarkerType.Warning, severity: 'warning' as const },
];
for (const { type, severity } of markerTypes) {
try {
const markers = await problemsView.getAllVisibleMarkers(type);
for (const marker of markers) {
const text = await marker.getText();
diagnostics.push({
severity,
message: text,
});
}
} catch {
// Marker type might not exist, continue
}
}
await bottomBar.toggle(false);
return {
success: true,
data: diagnostics,
message: `Found ${diagnostics.length} diagnostic messages`
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
success: false,
error: `Failed to get diagnostics: ${errorMessage}`
};
}
}
/**
* Get the content of the current editor
*/
public async getEditorContent(): Promise<ToolResult<{ content: string; info: EditorInfo }>> {
try {
const editorView = new EditorView();
const activeTab = await editorView.getActiveTab();
if (!activeTab) {
return {
success: false,
error: 'No active editor tab'
};
}
const title = await activeTab.getTitle();
const activeEditor = await editorView.openEditor(title);
if (!(activeEditor instanceof TextEditor)) {
return {
success: false,
error: 'Active editor is not a text editor'
};
}
const content = await activeEditor.getText();
const isDirty = await activeEditor.isDirty();
const info: EditorInfo = {
fileName: title,
isDirty: isDirty,
lineCount: content.split('\n').length,
};
return {
success: true,
data: { content, info }
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
success: false,
error: `Failed to get editor content: ${errorMessage}`
};
}
}
/**
* Open a webview by title
*/
public async openWebview(title: string): Promise<ToolResult<void>> {
try {
const workbench = await this.getWorkbench();
// Try to find and activate the webview by executing the command
const input = await workbench.openCommandPrompt() as QuickOpenBox;
await input.setText(`>${title}`);
await input.confirm();
await this.driver?.sleep(1000);
return {
success: true,
message: `Webview '${title}' opened`
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
success: false,
error: `Failed to open webview: ${errorMessage}`
};
}
}
/**
* List available commands
*/
public async listCommands(filter?: string): Promise<ToolResult<CommandInfo[]>> {
try {
const workbench = await this.getWorkbench();
const commands: CommandInfo[] = [];
// Open command palette
const input = await workbench.openCommandPrompt() as QuickOpenBox;
if (filter) {
await input.setText(`>${filter}`);
} else {
await input.setText('>');
}
await this.driver?.sleep(500);
// Get quick picks
const picks = await input.getQuickPicks();
for (const pick of picks.slice(0, 50)) { // Limit to 50 commands
try {
const label = await pick.getLabel();
const description = await pick.getDescription();
commands.push({
id: label,
title: label,
category: description,
});
} catch {
// Skip items that can't be read
}
}
// Cancel the command palette
await input.cancel();
return {
success: true,
data: commands,
message: `Found ${commands.length} commands`
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
success: false,
error: `Failed to list commands: ${errorMessage}`
};
}
}
/**
* Find an element using the specified selector
*/
private async findElement(selector: ElementSelector): Promise<WebElement | null> {
const driver = await this.getDriver();
const timeout = selector.timeout || 5000;
try {
let by: ReturnType<typeof By.css>;
switch (selector.type) {
case 'css':
by = By.css(selector.value);
break;
case 'xpath':
by = By.xpath(selector.value);
break;
case 'accessibility':
by = By.css(`[aria-label="${selector.value}"], [title="${selector.value}"]`);
break;
case 'text':
by = By.xpath(`//*[contains(text(), "${selector.value}")]`);
break;
default:
by = By.css(selector.value);
}
await driver.wait(until.elementLocated(by), timeout);
return await driver.findElement(by);
} catch {
return null;
}
}
/**
* Navigate to a specific panel in VSCode
*/
public async navigateToPanel(panelName: string): Promise<ToolResult<void>> {
try {
const activityBar = new ActivityBar();
const control = await activityBar.getViewControl(panelName);
if (!control) {
return {
success: false,
error: `Panel '${panelName}' not found in activity bar`
};
}
await control.openView();
return {
success: true,
message: `Navigated to panel '${panelName}'`
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
success: false,
error: `Failed to navigate to panel: ${errorMessage}`
};
}
}
/**
* Dispose of the driver and clean up resources
*/
public async dispose(): Promise<void> {
console.log('Disposing VSCode driver...');
try {
// Close the browser/driver first
if (this.driver) {
try {
await this.driver.quit();
console.log('WebDriver quit successfully');
} catch (error) {
console.error('Error quitting WebDriver:', error);
}
}
} catch (error) {
console.error('Error during dispose:', error);
} finally {
// Reset all state
this.state = 'disposed';
this.driver = null;
this.browser = null;
this.workbench = null;
this.exTester = null;
this.initPromise = null;
console.log('VSCode driver disposed');
}
}
/**
* Shutdown the driver (alias for dispose, for API compatibility)
*/
public async shutdown(): Promise<void> {
await this.dispose();
}
}
// Export singleton accessor
export function getVSCodeDriver(config?: Partial<VSCodeDriverConfig>): VSCodeDriver {
return VSCodeDriver.getInstance(config);
}