diff --git a/test/automation/src/playwrightDriver.ts b/test/automation/src/playwrightDriver.ts index b10b8b7a22e..21cf33e2965 100644 --- a/test/automation/src/playwrightDriver.ts +++ b/test/automation/src/playwrightDriver.ts @@ -66,7 +66,7 @@ export class PlaywrightDriver { constructor( private readonly application: playwright.Browser | playwright.ElectronApplication, private readonly context: playwright.BrowserContext, - private readonly page: playwright.Page, + private _currentPage: playwright.Page, private readonly serverProcess: ChildProcess | undefined, private readonly whenLoaded: Promise, private readonly options: LaunchOptions @@ -77,8 +77,284 @@ export class PlaywrightDriver { return this.context; } + private get page(): playwright.Page { + return this._currentPage; + } + get currentPage(): playwright.Page { - return this.page; + return this._currentPage; + } + + /** + * Get all open windows/pages. + * For Electron apps, returns all Electron windows. + * For browser contexts, returns all pages. + */ + getAllWindows(): playwright.Page[] { + if ('windows' in this.application) { + return (this.application as playwright.ElectronApplication).windows(); + } + return this.context.pages(); + } + + /** + * Switch to a different window by index or URL pattern. + * @param indexOrUrl - Window index (0-based) or a string to match against the URL. + * When using a string, it first tries to find an exact URL match, + * then falls back to finding the first URL that contains the pattern. + * @returns The switched-to page, or undefined if not found + * @note When switching windows, any existing CDP session will be cleared since it + * remains attached to the previous page and cannot be used with the new page. + */ + switchToWindow(indexOrUrl: number | string): playwright.Page | undefined { + const windows = this.getAllWindows(); + if (typeof indexOrUrl === 'number') { + if (indexOrUrl >= 0 && indexOrUrl < windows.length) { + this._currentPage = windows[indexOrUrl]; + // Clear CDP session as it's attached to the previous page + this._cdpSession = undefined; + return this._currentPage; + } + } else { + // First try exact match, then fall back to substring match + let found = windows.find(w => w.url() === indexOrUrl); + if (!found) { + found = windows.find(w => w.url().includes(indexOrUrl)); + } + if (found) { + this._currentPage = found; + // Clear CDP session as it's attached to the previous page + this._cdpSession = undefined; + return this._currentPage; + } + } + return undefined; + } + + /** + * Get information about all windows. + */ + getWindowsInfo(): { index: number; url: string; isCurrent: boolean }[] { + const windows = this.getAllWindows(); + return windows.map((p, index) => ({ + index, + url: p.url(), + isCurrent: p === this._currentPage + })); + } + + /** + * Take a screenshot of the current window. + * @param fullPage - Whether to capture the full scrollable page + * @returns Screenshot as a Buffer + */ + async screenshotBuffer(fullPage: boolean = false): Promise { + return await this.page.screenshot({ + type: 'png', + fullPage + }); + } + + /** + * Get the accessibility snapshot of the current window. + */ + async getAccessibilitySnapshot(): Promise Promise ? T : never> { + return await this.page.accessibility.snapshot(); + } + + /** + * Click on an element using CSS selector with options. + */ + async clickSelector(selector: string, options?: { button?: 'left' | 'right' | 'middle'; clickCount?: number }): Promise { + await this.page.click(selector, { + button: options?.button ?? 'left', + clickCount: options?.clickCount ?? 1 + }); + } + + /** + * Type text into an element. + * @param selector - CSS selector for the element + * @param text - Text to type + * @param slowly - Whether to type character by character (triggers key events) + */ + async typeText(selector: string, text: string, slowly: boolean = false): Promise { + if (slowly) { + await this.page.type(selector, text, { delay: 50 }); + } else { + await this.page.fill(selector, text); + } + } + + /** + * Evaluate a JavaScript expression in the current window. + */ + async evaluateExpression(expression: string): Promise { + return await this.page.evaluate(expression) as T; + } + + /** + * Get information about elements matching a selector. + */ + async getLocatorInfo(selector: string, action?: 'count' | 'textContent' | 'innerHTML' | 'boundingBox' | 'isVisible'): Promise< + number | string[] | { x: number; y: number; width: number; height: number } | null | boolean | { count: number; firstVisible: boolean } + > { + const locator = this.page.locator(selector); + + switch (action) { + case 'count': + return await locator.count(); + case 'textContent': + return await locator.allTextContents(); + case 'innerHTML': + return await locator.allInnerTexts(); + case 'boundingBox': + return await locator.first().boundingBox(); + case 'isVisible': + return await locator.first().isVisible(); + default: + return { + count: await locator.count(), + firstVisible: await locator.first().isVisible().catch(() => false) + }; + } + } + + /** + * Wait for an element to reach a specific state. + */ + async waitForElement(selector: string, options?: { state?: 'attached' | 'detached' | 'visible' | 'hidden'; timeout?: number }): Promise { + await this.page.waitForSelector(selector, { + state: options?.state ?? 'visible', + timeout: options?.timeout ?? 30000 + }); + } + + /** + * Hover over an element. + */ + async hoverSelector(selector: string): Promise { + await this.page.hover(selector); + } + + /** + * Drag from one element to another. + */ + async dragSelector(sourceSelector: string, targetSelector: string): Promise { + await this.page.dragAndDrop(sourceSelector, targetSelector); + } + + /** + * Press a key or key combination. + */ + async pressKey(key: string): Promise { + await this.page.keyboard.press(key); + } + + /** + * Move mouse to a specific position. + */ + async mouseMove(x: number, y: number): Promise { + await this.page.mouse.move(x, y); + } + + /** + * Click at a specific position. + */ + async mouseClick(x: number, y: number, options?: { button?: 'left' | 'right' | 'middle'; clickCount?: number }): Promise { + await this.page.mouse.click(x, y, { + button: options?.button ?? 'left', + clickCount: options?.clickCount ?? 1 + }); + } + + /** + * Drag from one position to another. + */ + async mouseDrag(startX: number, startY: number, endX: number, endY: number): Promise { + await this.page.mouse.move(startX, startY); + await this.page.mouse.down(); + await this.page.mouse.move(endX, endY); + await this.page.mouse.up(); + } + + /** + * Select an option in a dropdown. + */ + async selectOption(selector: string, value: string | string[]): Promise { + return await this.page.selectOption(selector, value); + } + + /** + * Fill multiple form fields at once. + */ + async fillForm(fields: { selector: string; value: string }[]): Promise { + for (const field of fields) { + await this.page.fill(field.selector, field.value); + } + } + + /** + * Get console messages from the current window. + */ + async getConsoleMessages(): Promise<{ type: string; text: string }[]> { + const messages = await this.page.consoleMessages(); + return messages.map(m => ({ + type: m.type(), + text: m.text() + })); + } + + /** + * Wait for text to appear, disappear, or a specified time to pass. + */ + async waitForText(options: { text?: string; textGone?: string; timeout?: number }): Promise { + const { text, textGone, timeout = 30000 } = options; + + if (text) { + await this.page.getByText(text).first().waitFor({ state: 'visible', timeout }); + } + if (textGone) { + await this.page.getByText(textGone).first().waitFor({ state: 'hidden', timeout }); + } + } + + /** + * Wait for a specified time in milliseconds. + */ + async waitForTime(ms: number): Promise { + await new Promise(resolve => setTimeout(resolve, ms)); + } + + /** + * Verify an element is visible. + */ + async verifyElementVisible(selector: string): Promise { + try { + await this.page.locator(selector).first().waitFor({ state: 'visible', timeout: 5000 }); + return true; + } catch { + return false; + } + } + + /** + * Verify text is visible on the page. + */ + async verifyTextVisible(text: string): Promise { + try { + await this.page.getByText(text).first().waitFor({ state: 'visible', timeout: 5000 }); + return true; + } catch { + return false; + } + } + + /** + * Get the value of an input element. + */ + async getInputValue(selector: string): Promise { + return await this.page.inputValue(selector); } async startTracing(name?: string): Promise { diff --git a/test/mcp/src/automationTools/index.ts b/test/mcp/src/automationTools/index.ts index 12c5d04a725..e5f595fb47f 100644 --- a/test/mcp/src/automationTools/index.ts +++ b/test/mcp/src/automationTools/index.ts @@ -25,6 +25,7 @@ import { applyLocalizationTools } from './localization.js'; import { applyTaskTools } from './task.js'; import { applyProfilerTools } from './profiler.js'; import { applyChatTools } from './chat.js'; +import { applyWindowTools } from './windows.js'; import { ApplicationService } from '../application'; /** @@ -93,6 +94,9 @@ export function applyAllTools(server: McpServer, appService: ApplicationService) // Chat Tools tools = tools.concat(applyChatTools(server, appService)); + // Window Management Tools (for multi-window support) + tools = tools.concat(applyWindowTools(server, appService)); + // Return all registered tools return tools; } @@ -117,5 +121,6 @@ export { applyLocalizationTools, applyTaskTools, applyProfilerTools, - applyChatTools + applyChatTools, + applyWindowTools }; diff --git a/test/mcp/src/automationTools/windows.ts b/test/mcp/src/automationTools/windows.ts new file mode 100644 index 00000000000..de166b8d4da --- /dev/null +++ b/test/mcp/src/automationTools/windows.ts @@ -0,0 +1,387 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { ApplicationService } from '../application'; + +/** + * Create a standardized text response for window tools + */ +function textResponse(text: string) { + return { + content: [{ type: 'text' as const, text }] + }; +} + +/** + * Window Management Tools for multi-window support. + * These tools are thin wrappers around PlaywrightDriver methods. + */ +export function applyWindowTools(server: McpServer, appService: ApplicationService): RegisteredTool[] { + const tools: RegisteredTool[] = []; + + tools.push(server.tool( + 'vscode_automation_list_windows', + 'List all open VS Code windows with their index and URL', + async () => { + const app = await appService.getOrCreateApplication(); + const windowInfo = app.code.driver.getWindowsInfo(); + return textResponse(`Open windows (${windowInfo.length}):\n${JSON.stringify(windowInfo, null, 2)}`); + } + )); + + tools.push(server.tool( + 'vscode_automation_switch_window', + 'Switch to a different VS Code window by index or URL pattern (e.g., "agent.html")', + { + indexOrUrl: z.union([z.number(), z.string()]).describe('Window index (0-based) or URL pattern to match (e.g., "agent.html", "workbench")') + }, + async ({ indexOrUrl }) => { + const app = await appService.getOrCreateApplication(); + const switched = app.code.driver.switchToWindow(indexOrUrl); + + if (switched) { + return textResponse(`Switched to window (URL: ${switched.url()})`); + } + + const windowInfo = app.code.driver.getWindowsInfo(); + const availableWindows = windowInfo.map(w => ` ${w.index}: ${w.url}`).join('\n'); + return textResponse(`Failed to switch window. Window not found for: ${indexOrUrl}\n\nAvailable windows:\n${availableWindows}`); + } + )); + + tools.push(server.tool( + 'vscode_automation_get_current_window', + 'Get information about the currently active window', + async () => { + const app = await appService.getOrCreateApplication(); + const driver = app.code.driver; + const windowInfo = driver.getWindowsInfo(); + const current = windowInfo.find(w => w.isCurrent); + return textResponse(`Current window:\nIndex: ${current?.index ?? -1}\nURL: ${current?.url ?? 'Unknown'}`); + } + )); + + tools.push(server.tool( + 'vscode_automation_window_screenshot', + 'Take a screenshot of the current window (respects the window set by vscode_automation_switch_window)', + { + fullPage: z.boolean().optional().describe('When true, takes a screenshot of the full scrollable page') + }, + async ({ fullPage }) => { + const app = await appService.getOrCreateApplication(); + const driver = app.code.driver; + const screenshotBuffer = await driver.screenshotBuffer(fullPage ?? false); + const url = driver.currentPage.url(); + + return { + content: [ + { type: 'text' as const, text: `Screenshot (URL: ${url})` }, + { type: 'image' as const, data: screenshotBuffer.toString('base64'), mimeType: 'image/png' } + ] + }; + } + )); + + tools.push(server.tool( + 'vscode_automation_window_snapshot', + 'Capture accessibility snapshot of the current window. Returns the page structure with element references that can be used for interactions.', + async () => { + const app = await appService.getOrCreateApplication(); + const driver = app.code.driver; + const snapshot = await driver.getAccessibilitySnapshot(); + const url = driver.currentPage.url(); + + return textResponse(`Page snapshot (URL: ${url}):\n\n${JSON.stringify(snapshot, null, 2)}`); + } + )); + + tools.push(server.tool( + 'vscode_automation_window_click', + 'Click on an element in the current window using a CSS selector', + { + selector: z.string().describe('CSS selector for the element to click'), + button: z.enum(['left', 'right', 'middle']).optional().describe('Mouse button to click'), + clickCount: z.number().optional().describe('Number of clicks (1 for single, 2 for double)') + }, + async ({ selector, button, clickCount }) => { + const app = await appService.getOrCreateApplication(); + const driver = app.code.driver; + await driver.clickSelector(selector, { button, clickCount }); + return textResponse(`Clicked "${selector}"`); + } + )); + + tools.push(server.tool( + 'vscode_automation_window_type', + 'Type text into an element in the current window', + { + selector: z.string().describe('CSS selector for the element to type into'), + text: z.string().describe('Text to type'), + slowly: z.boolean().optional().describe('Whether to type one character at a time (useful for triggering key handlers)') + }, + async ({ selector, text, slowly }) => { + const app = await appService.getOrCreateApplication(); + const driver = app.code.driver; + await driver.typeText(selector, text, slowly ?? false); + return textResponse(`Typed "${text}" into "${selector}"`); + } + )); + + tools.push(server.tool( + 'vscode_automation_window_evaluate', + 'Evaluate JavaScript in the current window', + { + expression: z.string().describe('JavaScript expression to evaluate') + }, + async ({ expression }) => { + const app = await appService.getOrCreateApplication(); + const driver = app.code.driver; + const result = await driver.evaluateExpression(expression); + return textResponse(`Result:\n${JSON.stringify(result, null, 2)}`); + } + )); + + tools.push(server.tool( + 'vscode_automation_window_locator', + 'Get information about elements matching a selector in the current window', + { + selector: z.string().describe('CSS selector to find elements'), + action: z.enum(['count', 'textContent', 'innerHTML', 'boundingBox', 'isVisible']).optional().describe('Action to perform on matched elements') + }, + async ({ selector, action }) => { + const app = await appService.getOrCreateApplication(); + const driver = app.code.driver; + const result = await driver.getLocatorInfo(selector, action); + return textResponse(`Locator "${selector}":\n${JSON.stringify(result, null, 2)}`); + } + )); + + tools.push(server.tool( + 'vscode_automation_window_wait_for_selector', + 'Wait for an element to appear in the current window', + { + selector: z.string().describe('CSS selector to wait for'), + state: z.enum(['attached', 'detached', 'visible', 'hidden']).optional().describe('State to wait for'), + timeout: z.number().optional().describe('Timeout in milliseconds') + }, + async ({ selector, state, timeout }) => { + const app = await appService.getOrCreateApplication(); + const driver = app.code.driver; + await driver.waitForElement(selector, { state, timeout }); + return textResponse(`Element "${selector}" is now ${state ?? 'visible'}`); + } + )); + + tools.push(server.tool( + 'vscode_automation_window_hover', + 'Hover over an element in the current window', + { + selector: z.string().describe('CSS selector for the element to hover over') + }, + async ({ selector }) => { + const app = await appService.getOrCreateApplication(); + const driver = app.code.driver; + await driver.hoverSelector(selector); + return textResponse(`Hovered over "${selector}"`); + } + )); + + tools.push(server.tool( + 'vscode_automation_window_drag', + 'Drag from one element to another in the current window', + { + sourceSelector: z.string().describe('CSS selector for the source element'), + targetSelector: z.string().describe('CSS selector for the target element') + }, + async ({ sourceSelector, targetSelector }) => { + const app = await appService.getOrCreateApplication(); + const driver = app.code.driver; + await driver.dragSelector(sourceSelector, targetSelector); + return textResponse(`Dragged from "${sourceSelector}" to "${targetSelector}"`); + } + )); + + tools.push(server.tool( + 'vscode_automation_window_press_key', + 'Press a key or key combination in the current window', + { + key: z.string().describe('Key to press (e.g., "Enter", "Tab", "Control+c", "Meta+v")') + }, + async ({ key }) => { + const app = await appService.getOrCreateApplication(); + const driver = app.code.driver; + await driver.pressKey(key); + return textResponse(`Pressed key "${key}"`); + } + )); + + tools.push(server.tool( + 'vscode_automation_window_mouse_move', + 'Move mouse to a specific position in the current window', + { + x: z.number().describe('X coordinate'), + y: z.number().describe('Y coordinate') + }, + async ({ x, y }) => { + const app = await appService.getOrCreateApplication(); + const driver = app.code.driver; + await driver.mouseMove(x, y); + return textResponse(`Moved mouse to (${x}, ${y})`); + } + )); + + tools.push(server.tool( + 'vscode_automation_window_mouse_click', + 'Click at a specific position in the current window', + { + x: z.number().describe('X coordinate'), + y: z.number().describe('Y coordinate'), + button: z.enum(['left', 'right', 'middle']).optional().describe('Mouse button to click'), + clickCount: z.number().optional().describe('Number of clicks (1 for single, 2 for double)') + }, + async ({ x, y, button, clickCount }) => { + const app = await appService.getOrCreateApplication(); + const driver = app.code.driver; + await driver.mouseClick(x, y, { button, clickCount }); + return textResponse(`Clicked at (${x}, ${y})`); + } + )); + + tools.push(server.tool( + 'vscode_automation_window_mouse_drag', + 'Drag from one position to another in the current window', + { + startX: z.number().describe('Starting X coordinate'), + startY: z.number().describe('Starting Y coordinate'), + endX: z.number().describe('Ending X coordinate'), + endY: z.number().describe('Ending Y coordinate') + }, + async ({ startX, startY, endX, endY }) => { + const app = await appService.getOrCreateApplication(); + const driver = app.code.driver; + await driver.mouseDrag(startX, startY, endX, endY); + return textResponse(`Dragged from (${startX}, ${startY}) to (${endX}, ${endY})`); + } + )); + + tools.push(server.tool( + 'vscode_automation_window_select_option', + 'Select an option in a dropdown in the current window', + { + selector: z.string().describe('CSS selector for the select element'), + value: z.union([z.string(), z.array(z.string())]).describe('Value(s) to select') + }, + async ({ selector, value }) => { + const app = await appService.getOrCreateApplication(); + const driver = app.code.driver; + const selected = await driver.selectOption(selector, value); + return textResponse(`Selected "${selected.join(', ')}" in "${selector}"`); + } + )); + + tools.push(server.tool( + 'vscode_automation_window_fill_form', + 'Fill multiple form fields at once in the current window', + { + fields: z.array(z.object({ + selector: z.string().describe('CSS selector for the form field'), + value: z.string().describe('Value to fill') + })).describe('Array of fields to fill') + }, + async ({ fields }) => { + const app = await appService.getOrCreateApplication(); + const driver = app.code.driver; + await driver.fillForm(fields); + return textResponse(`Filled ${fields.length} form field(s)`); + } + )); + + tools.push(server.tool( + 'vscode_automation_window_console_messages', + 'Get console messages from the current window', + async () => { + const app = await appService.getOrCreateApplication(); + const driver = app.code.driver; + const messages = await driver.getConsoleMessages(); + return textResponse(`Console messages (${messages.length}):\n${JSON.stringify(messages, null, 2)}`); + } + )); + + tools.push(server.tool( + 'vscode_automation_window_wait_for_text', + 'Wait for text to appear or disappear in the current window', + { + text: z.string().optional().describe('Text to wait for to appear'), + textGone: z.string().optional().describe('Text to wait for to disappear'), + timeout: z.number().optional().describe('Timeout in milliseconds (default: 30000)') + }, + async ({ text, textGone, timeout }) => { + const app = await appService.getOrCreateApplication(); + const driver = app.code.driver; + await driver.waitForText({ text, textGone, timeout }); + return textResponse(`Waited for ${text ? `"${text}" to appear` : ''}${textGone ? `"${textGone}" to disappear` : ''}`); + } + )); + + tools.push(server.tool( + 'vscode_automation_window_wait_for_time', + 'Wait for a specified time in the current window', + { + seconds: z.number().describe('Time to wait in seconds') + }, + async ({ seconds }) => { + const app = await appService.getOrCreateApplication(); + const driver = app.code.driver; + await driver.waitForTime(seconds * 1000); + return textResponse(`Waited for ${seconds} second(s)`); + } + )); + + tools.push(server.tool( + 'vscode_automation_window_verify_element_visible', + 'Verify an element is visible in the current window', + { + selector: z.string().describe('CSS selector for the element to verify') + }, + async ({ selector }) => { + const app = await appService.getOrCreateApplication(); + const driver = app.code.driver; + const isVisible = await driver.verifyElementVisible(selector); + return textResponse(isVisible ? `✓ Element "${selector}" is visible` : `✗ Element "${selector}" is NOT visible`); + } + )); + + tools.push(server.tool( + 'vscode_automation_window_verify_text_visible', + 'Verify text is visible in the current window', + { + text: z.string().describe('Text to verify is visible') + }, + async ({ text }) => { + const app = await appService.getOrCreateApplication(); + const driver = app.code.driver; + const isVisible = await driver.verifyTextVisible(text); + return textResponse(isVisible ? `✓ Text "${text}" is visible` : `✗ Text "${text}" is NOT visible`); + } + )); + + tools.push(server.tool( + 'vscode_automation_window_get_input_value', + 'Get the value of an input element in the current window', + { + selector: z.string().describe('CSS selector for the input element') + }, + async ({ selector }) => { + const app = await appService.getOrCreateApplication(); + const driver = app.code.driver; + const value = await driver.getInputValue(selector); + return textResponse(`Input "${selector}" value: "${value}"`); + } + )); + + return tools; +} diff --git a/test/mcp/src/multiplex.ts b/test/mcp/src/multiplex.ts index b5f96f0d709..5fe84030d89 100644 --- a/test/mcp/src/multiplex.ts +++ b/test/mcp/src/multiplex.ts @@ -48,11 +48,54 @@ export async function getServer(): Promise { multiplexServer.addSubServer({ subServer: playwrightClient, excludeTools: [ - // The page will always be opened in the context of the application, - // so navigation and tab management is not needed. + // Playwright MCP doesn't properly support Electron's multi-window model. + // It uses browserContext.pages() which doesn't track Electron windows correctly. + // We provide vscode_automation_window_* alternatives that use ElectronApplication.windows(). + + // Navigation not needed - VS Code opens its own windows 'browser_navigate', 'browser_navigate_back', - 'browser_tabs' + 'browser_tabs', + + // Page interaction tools - replaced by vscode_automation_window_* + 'browser_click', // → vscode_automation_window_click + 'browser_type', // → vscode_automation_window_type + 'browser_hover', // → vscode_automation_window_hover + 'browser_drag', // → vscode_automation_window_drag + 'browser_select_option', // → vscode_automation_window_select_option + 'browser_fill_form', // → vscode_automation_window_fill_form + 'browser_press_key', // → vscode_automation_window_press_key + + // Mouse operations - replaced by vscode_automation_window_mouse_* + 'browser_mouse_move_xy', // → vscode_automation_window_mouse_move + 'browser_mouse_click_xy', // → vscode_automation_window_mouse_click + 'browser_mouse_drag_xy', // → vscode_automation_window_mouse_drag + + // Content capture - replaced by vscode_automation_window_* + 'browser_snapshot', // → vscode_automation_window_snapshot + 'browser_take_screenshot', // → vscode_automation_window_screenshot + 'browser_evaluate', // → vscode_automation_window_evaluate + + // Console/debugging - replaced by vscode_automation_window_* + 'browser_console_messages', // → vscode_automation_window_console_messages + + // Wait/timing - replaced by vscode_automation_window_* + 'browser_wait_for', // → vscode_automation_window_wait_for_text / wait_for_time + + // Verification - replaced by vscode_automation_window_* + 'browser_verify_element_visible', // → vscode_automation_window_verify_element_visible + 'browser_verify_text_visible', // → vscode_automation_window_verify_text_visible + 'browser_verify_list_visible', // (no direct replacement - use multiple verify_text_visible) + 'browser_verify_value', // → vscode_automation_window_get_input_value + + // Other page-dependent tools (not typically needed for VS Code testing) + 'browser_close', + 'browser_resize', + 'browser_network_requests', + 'browser_file_upload', + 'browser_handle_dialog', + 'browser_pdf_save', + 'browser_generate_locator' ] }); multiplexServer.sendToolListChanged();