UNPKG

electron-playwright-mcp

Version:
614 lines (613 loc) 22 kB
import { _electron } from '@playwright/test'; import { join } from 'path'; import { existsSync, mkdirSync } from 'fs'; import { tmpdir } from 'os'; export class ElectronBrowserManager { electronApp = null; currentPage = null; pages = []; networkRequests = []; consoleMessages = []; screenshotDir = join(tmpdir(), 'electron-playwright-mcp'); elementRefMap = new Map(); refCounter = 100; constructor() { this.ensureScreenshotDir(); } async initBrowserManager(executablePath) { this.electronApp = await _electron.launch({ executablePath, timeout: 0, args: [] }); this.currentPage = await this.electronApp.firstWindow(); this.pages = [this.currentPage]; this.setupEventListeners(); } ensureScreenshotDir() { if (!existsSync(this.screenshotDir)) { mkdirSync(this.screenshotDir, { recursive: true }); } } setupEventListeners() { if (!this.currentPage) return; // Track network requests this.currentPage.on('request', (request) => { this.networkRequests.push({ url: request.url(), method: request.method(), timestamp: Date.now() }); }); this.currentPage.on('response', (response) => { const existing = this.networkRequests.find((r) => r.url === response.url() && !r.status); if (existing) { existing.status = response.status(); existing.contentType = response.headers()['content-type']; } }); // Track console messages this.currentPage.on('console', (message) => { this.consoleMessages.push({ type: message.type(), text: message.text(), timestamp: Date.now(), location: message.location()?.url }); }); // Handle dialogs this.currentPage.on('dialog', async (dialog) => { console.log(`Dialog appeared: ${dialog.type()} - ${dialog.message()}`); // Auto-dismiss for now, can be controlled via browser_handle_dialog await dialog.dismiss(); }); } async navigate(params) { if (!params.url || params.url === '') { // Navigate to first available browser window (already done) const title = await this.currentPage.title(); const url = this.currentPage.url(); return { content: [ { type: 'text', text: `Navigated to: ${title}\nURL: ${url}\nStatus: success` } ] }; } else { await this.currentPage.goto(params.url); const title = await this.currentPage.title(); const url = this.currentPage.url(); return { content: [ { type: 'text', text: `Navigated to: ${title}\nURL: ${url}\nStatus: success` } ] }; } } async navigateBack() { if (!this.currentPage) throw new Error('Browser not initialized'); await this.currentPage.goBack(); const title = await this.currentPage.title(); const url = this.currentPage.url(); return { content: [ { type: 'text', text: `Navigated back to: ${title}\nURL: ${url}` } ] }; } async click(params) { if (!this.currentPage) throw new Error('Browser not initialized'); const selector = this.elementRefMap.get(params.ref); if (!selector) { throw new Error(`Element reference ${params.ref} not found. Please take a snapshot first.`); } await this.currentPage.click(selector); return { content: [ { type: 'text', text: `Clicked on ${params.element} (ref: ${params.ref})` } ] }; } async type(params) { if (!this.currentPage) throw new Error('Browser not initialized'); const selector = this.elementRefMap.get(params.ref); if (!selector) { throw new Error(`Element reference ${params.ref} not found. Please take a snapshot first.`); } if (params.slowly) { await this.currentPage.type(selector, params.text, { delay: 100 }); } else { await this.currentPage.fill(selector, params.text); } if (params.submit) { await this.currentPage.press(selector, 'Enter'); } return { content: [ { type: 'text', text: `Typed "${params.text}" into ${params.element} (ref: ${params.ref})` } ] }; } async pressKey(params) { if (!this.currentPage) throw new Error('Browser not initialized'); await this.currentPage.keyboard.press(params.key); return { content: [ { type: 'text', text: `Pressed key: ${params.key}` } ] }; } async fillForm(params) { if (!this.currentPage) throw new Error('Browser not initialized'); const results = []; for (const field of params.fields) { const selector = this.elementRefMap.get(field.ref); if (!selector) { throw new Error(`Element reference ${field.ref} not found for field ${field.name}`); } if (field.type === 'checkbox') { const isChecked = field.value === 'true'; await this.currentPage.setChecked(selector, isChecked); results.push(`${field.name}: ${isChecked ? 'checked' : 'unchecked'}`); } else { await this.currentPage.fill(selector, field.value); results.push(`${field.name}: "${field.value}"`); } } return { content: [ { type: 'text', text: `Filled form fields:\n${results.join('\n')}` } ] }; } async selectOption(params) { if (!this.currentPage) throw new Error('Browser not initialized'); const selector = this.elementRefMap.get(params.ref); if (!selector) { throw new Error(`Element reference ${params.ref} not found`); } await this.currentPage.selectOption(selector, params.values); return { content: [ { type: 'text', text: `Selected options in ${params.element}: ${params.values.join(', ')}` } ] }; } async hover(params) { if (!this.currentPage) throw new Error('Browser not initialized'); const selector = this.elementRefMap.get(params.ref); if (!selector) { throw new Error(`Element reference ${params.ref} not found`); } await this.currentPage.hover(selector); return { content: [ { type: 'text', text: `Hovered over ${params.element} (ref: ${params.ref})` } ] }; } async drag(params) { if (!this.currentPage) throw new Error('Browser not initialized'); const startSelector = this.elementRefMap.get(params.startRef); const endSelector = this.elementRefMap.get(params.endRef); if (!startSelector) { throw new Error(`Start element reference ${params.startRef} not found`); } if (!endSelector) { throw new Error(`End element reference ${params.endRef} not found`); } await this.currentPage.dragAndDrop(startSelector, endSelector); return { content: [ { type: 'text', text: `Dragged ${params.startElement} to ${params.endElement}` } ] }; } async snapshot() { if (!this.currentPage) throw new Error('Browser not initialized'); // Clear previous element mappings this.elementRefMap.clear(); this.refCounter = 100; const elements = await this.currentPage.evaluate(() => { const elements = []; let refCounter = 100; function getSelector(el) { // Try to build a unique selector if (el.id) return `#${CSS.escape(el.id)}`; const tagName = el.tagName.toLowerCase(); let selector = tagName; if (el.className) { const classes = el.className.split(' ').filter((c) => c.trim()); if (classes.length > 0) { selector += '.' + classes.join('.'); } } // Add attribute selectors for uniqueness const attrs = ['name', 'type', 'placeholder', 'aria-label']; for (const attr of attrs) { const value = el.getAttribute(attr); if (value) { selector += `[${attr}="${value}"]`; break; } } return selector; } function processElement(el, depth = 0) { const tagName = el.tagName.toLowerCase(); const text = el.textContent?.trim().substring(0, 100) || ''; const role = el.getAttribute('role') || el.type || tagName; // Include interactive elements and elements with text if (text || [ 'button', 'input', 'select', 'textarea', 'a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'label', 'option' ].includes(tagName)) { const ref = `e${refCounter++}`; const selector = getSelector(el); elements.push({ ref, role, name: text, tag: tagName, depth, clickable: ['button', 'a', 'input', 'select', 'textarea'].includes(tagName) || el.getAttribute('onclick') !== null || el.getAttribute('role') === 'button', type: el.type || undefined, value: el.value || undefined, selector, attributes: { id: el.id || undefined, className: el.className || undefined, name: el.getAttribute('name') || undefined, placeholder: el.getAttribute('placeholder') || undefined } }); } // Process children Array.from(el.children).forEach((child) => processElement(child, depth + 1)); } if (document.body) { processElement(document.body); } return elements; }); // Build element reference map elements.forEach((element) => { this.elementRefMap.set(element.ref, element.selector); }); // Format as YAML-like structure let yamlOutput = 'page:\n'; yamlOutput += ` url: ${this.currentPage.url()}\n`; yamlOutput += ` title: ${await this.currentPage.title()}\n`; yamlOutput += ' elements:\n'; elements.forEach((element) => { const indent = ' '.repeat(element.depth + 2); yamlOutput += `${indent}- ref: ${element.ref}\n`; yamlOutput += `${indent} role: ${element.role}\n`; yamlOutput += `${indent} name: "${element.name}"\n`; yamlOutput += `${indent} tag: ${element.tag}\n`; if (element.clickable) yamlOutput += `${indent} clickable: true\n`; if (element.type) yamlOutput += `${indent} type: ${element.type}\n`; if (element.value) yamlOutput += `${indent} value: "${element.value}"\n`; }); return { content: [ { type: 'text', text: `Page snapshot captured with ${elements.length} elements:\n\n${yamlOutput}` } ] }; } async takeScreenshot(params = {}) { if (!this.currentPage) throw new Error('Browser not initialized'); const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const filename = params.filename || `screenshot-${timestamp}.${params.type || 'png'}`; const screenshotPath = join(this.screenshotDir, filename); const screenshotType = params.type === 'jpeg' ? 'jpeg' : 'png'; const options = { path: screenshotPath, fullPage: params.fullPage || false, type: screenshotType }; if (params.element && params.ref) { const selector = this.elementRefMap.get(params.ref); if (!selector) { throw new Error(`Element reference ${params.ref} not found`); } const element = this.currentPage.locator(selector); await element.screenshot(options); } else { await this.currentPage.screenshot(options); } return { content: [ { type: 'text', text: `Screenshot saved to: ${screenshotPath}` } ] }; } async evaluate(params) { if (!this.currentPage) throw new Error('Browser not initialized'); try { let result; if (params.element && params.ref) { const selector = this.elementRefMap.get(params.ref); if (!selector) { throw new Error(`Element reference ${params.ref} not found`); } result = await this.currentPage.locator(selector).evaluate(params.function); } else { // Evaluate in page context const func = new Function('return ' + params.function)(); result = await this.currentPage.evaluate(func); } return { content: [ { type: 'text', text: `JavaScript evaluation result: ${JSON.stringify(result, null, 2)}` } ] }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`JavaScript evaluation failed: ${errorMessage}`); } } async fileUpload(params) { if (!this.currentPage) throw new Error('Browser not initialized'); // Find file input elements const fileInputs = await this.currentPage.locator('input[type="file"]').all(); if (fileInputs.length === 0) { throw new Error('No file input elements found on the page'); } await fileInputs[0].setInputFiles(params.paths); return { content: [ { type: 'text', text: `Uploaded files: ${params.paths.join(', ')}` } ] }; } async manageTabs(params) { if (!this.electronApp) throw new Error('Browser not initialized'); switch (params.action) { case 'list': { const pages = this.electronApp.windows(); return { content: [ { type: 'text', text: `Open windows: ${pages.length}\nCurrent: ${this.pages.indexOf(this.currentPage)}` } ] }; } case 'new': { // In Electron, we work with windows, not tabs const newWindow = await this.electronApp.firstWindow(); this.pages.push(newWindow); return { content: [ { type: 'text', text: 'New window created' } ] }; } case 'close': { if (params.index !== undefined && this.pages[params.index]) { await this.pages[params.index].close(); this.pages.splice(params.index, 1); } return { content: [ { type: 'text', text: `Closed window at index ${params.index}` } ] }; } case 'select': { if (params.index !== undefined && this.pages[params.index]) { this.currentPage = this.pages[params.index]; await this.currentPage.bringToFront(); } return { content: [ { type: 'text', text: `Switched to window at index ${params.index}` } ] }; } default: throw new Error(`Unknown tab action: ${params.action}`); } } async handleDialog(params) { if (!this.currentPage) throw new Error('Browser not initialized'); // Set up dialog handler this.currentPage.once('dialog', async (dialog) => { if (params.accept) { await dialog.accept(params.promptText || ''); } else { await dialog.dismiss(); } }); return { content: [ { type: 'text', text: `Dialog handler set: ${params.accept ? 'accept' : 'dismiss'}` } ] }; } async waitFor(params) { if (!this.currentPage) throw new Error('Browser not initialized'); if (params.text) { await this.currentPage.waitForSelector(`text=${params.text}`, { timeout: 30000 }); return { content: [ { type: 'text', text: `Waited for text: "${params.text}"` } ] }; } if (params.textGone) { await this.currentPage.waitForSelector(`text=${params.textGone}`, { state: 'detached', timeout: 30000 }); return { content: [ { type: 'text', text: `Waited for text to disappear: "${params.textGone}"` } ] }; } if (params.time) { await this.currentPage.waitForTimeout(params.time * 1000); return { content: [ { type: 'text', text: `Waited for ${params.time} seconds` } ] }; } throw new Error('No wait condition specified'); } async resize(params) { if (!this.currentPage) throw new Error('Browser not initialized'); await this.currentPage.setViewportSize({ width: params.width, height: params.height }); return { content: [ { type: 'text', text: `Resized browser to ${params.width}x${params.height}` } ] }; } async close() { if (this.electronApp) { await this.electronApp.close(); this.electronApp = null; this.currentPage = null; this.pages = []; } return { content: [ { type: 'text', text: 'Browser closed' } ] }; } async getNetworkRequests() { return { content: [ { type: 'text', text: `Network requests (${this.networkRequests.length}):\n${JSON.stringify(this.networkRequests, null, 2)}` } ] }; } async getConsoleMessages() { return { content: [ { type: 'text', text: `Console messages (${this.consoleMessages.length}):\n${JSON.stringify(this.consoleMessages, null, 2)}` } ] }; } }