diff --git a/playwriter/src/cdp-relay.ts b/playwriter/src/cdp-relay.ts index a2e9d27..e39f8b7 100644 --- a/playwriter/src/cdp-relay.ts +++ b/playwriter/src/cdp-relay.ts @@ -1014,6 +1014,84 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127. } })) + // ============================================================================ + // CLI Execute Endpoints - For stateful code execution via CLI + // ============================================================================ + + // Session counter for suggesting next session number + let nextSessionNumber = 1 + + // Lazy-load ExecutorManager to avoid circular imports and only when needed + let executorManager: import('./executor.js').ExecutorManager | null = null + + const getExecutorManager = async () => { + if (!executorManager) { + const { ExecutorManager } = await import('./executor.js') + // Pass config instead of URL so executor can generate unique client IDs for each connection + executorManager = new ExecutorManager({ + cdpConfig: { host: '127.0.0.1', port }, + logger: logger || { log: console.log, error: console.error }, + }) + } + return executorManager + } + + app.post('/cli/execute', async (c) => { + try { + const body = await c.req.json() as { sessionId: string; code: string; timeout?: number; cwd?: string } + const { sessionId, code, timeout = 5000, cwd } = body + + if (!sessionId || !code) { + return c.json({ error: 'sessionId and code are required' }, 400) + } + + const manager = await getExecutorManager() + const executor = manager.getExecutor(sessionId, cwd) + const result = await executor.execute(code, timeout) + + // Increment session counter after each execution to avoid conflicts + nextSessionNumber++ + + return c.json(result) + } catch (error: any) { + logger?.error('Execute endpoint error:', error) + return c.json({ text: `Server error: ${error.message}`, images: [], isError: true }, 500) + } + }) + + app.post('/cli/reset', async (c) => { + try { + const body = await c.req.json() as { sessionId: string; cwd?: string } + const { sessionId, cwd } = body + + if (!sessionId) { + return c.json({ error: 'sessionId is required' }, 400) + } + + const manager = await getExecutorManager() + const executor = manager.getExecutor(sessionId, cwd) + const { page, context } = await executor.reset() + + return c.json({ + success: true, + pageUrl: page.url(), + pagesCount: context.pages().length, + }) + } catch (error: any) { + logger?.error('Reset endpoint error:', error) + return c.json({ error: error.message }, 500) + } + }) + + app.get('/cli/sessions', async (c) => { + const manager = await getExecutorManager() + return c.json({ sessions: manager.listSessions() }) + }) + + app.get('/cli/session/suggest', (c) => { + return c.json({ next: nextSessionNumber }) + }) + const server = serve({ fetch: app.fetch, port, hostname: host }) injectWebSocket(server) diff --git a/playwriter/src/cli.ts b/playwriter/src/cli.ts index 5ff5f92..6dd0ff4 100644 --- a/playwriter/src/cli.ts +++ b/playwriter/src/cli.ts @@ -4,17 +4,32 @@ import { cac } from 'cac' import { startPlayWriterCDPRelayServer } from './cdp-relay.js' import { createFileLogger } from './create-logger.js' import { VERSION } from './utils.js' +import { ensureRelayServer, RELAY_PORT } from './relay-client.js' import { killPortProcess } from 'kill-port-process' -const RELAY_PORT = 19988 - const cli = cac('playwriter') cli .command('', 'Start the MCP server (default)') .option('--host ', 'Remote relay server host to connect to (or use PLAYWRITER_HOST env var)') .option('--token ', 'Authentication token (or use PLAYWRITER_TOKEN env var)') - .action(async (options: { host?: string; token?: string }) => { + .option('-e, --eval ', 'Execute JavaScript code and exit') + .option('--timeout ', 'Execution timeout in milliseconds', { default: 5000 }) + .option('-s, --session ', 'Session name (required for -e)') + .action(async (options: { host?: string; token?: string; eval?: string; timeout?: number; session?: string }) => { + // If -e flag is provided, execute code via relay server + if (options.eval) { + await executeCode({ + code: options.eval, + timeout: options.timeout || 5000, + sessionId: options.session, + host: options.host, + token: options.token, + }) + return + } + + // Otherwise start the MCP server const { startMcp } = await import('./mcp.js') await startMcp({ host: options.host, @@ -22,6 +37,133 @@ cli }) }) +async function executeCode(options: { + code: string + timeout: number + sessionId?: string + host?: string + token?: string +}): Promise { + const { code, timeout, host, token } = options + const cwd = process.cwd() + const sessionId = options.sessionId || process.env.PLAYWRITER_SESSION + + // Determine server URL + const serverHost = host || process.env.PLAYWRITER_HOST || '127.0.0.1' + const serverUrl = `http://${serverHost}:${RELAY_PORT}` + + // Ensure relay server is running (only for local) + if (!host && !process.env.PLAYWRITER_HOST) { + await ensureRelayServer({ logger: console }) + } + + // Session is required + if (!sessionId) { + try { + const res = await fetch(`${serverUrl}/cli/session/suggest`) + const { next } = await res.json() as { next: number } + console.error(`Error: -s/--session is required. Use -s ${next} for a new session.`) + } catch { + console.error(`Error: -s/--session is required. Use -s 1 for a new session.`) + } + process.exit(1) + } + + // Build request URL with token if provided + const executeUrl = `${serverUrl}/cli/execute` + + try { + const response = await fetch(executeUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token || process.env.PLAYWRITER_TOKEN ? { 'Authorization': `Bearer ${token || process.env.PLAYWRITER_TOKEN}` } : {}), + }, + body: JSON.stringify({ sessionId, code, timeout, cwd }), + }) + + if (!response.ok) { + const text = await response.text() + console.error(`Error: ${response.status} ${text}`) + process.exit(1) + } + + const result = await response.json() as { text: string; images: Array<{ data: string; mimeType: string }>; isError: boolean } + + // Print output + if (result.text) { + if (result.isError) { + console.error(result.text) + } else { + console.log(result.text) + } + } + + // Note: images are base64 encoded, we could save them to files if needed + if (result.images && result.images.length > 0) { + console.log(`\n${result.images.length} screenshot(s) captured`) + } + + if (result.isError) { + process.exit(1) + } + } catch (error: any) { + if (error.cause?.code === 'ECONNREFUSED') { + console.error('Error: Cannot connect to relay server. Make sure the Playwriter extension is running.') + } else { + console.error(`Error: ${error.message}`) + } + process.exit(1) + } +} + +// Reset command for CLI +cli + .command('reset', 'Reset the browser connection for current session') + .option('-s, --session ', 'Session name (required)') + .option('--host ', 'Remote relay server host') + .action(async (options: { session?: string; host?: string }) => { + const cwd = process.cwd() + const sessionId = options.session || process.env.PLAYWRITER_SESSION + const serverHost = options.host || process.env.PLAYWRITER_HOST || '127.0.0.1' + const serverUrl = `http://${serverHost}:${RELAY_PORT}` + + if (!options.host && !process.env.PLAYWRITER_HOST) { + await ensureRelayServer({ logger: console }) + } + + if (!sessionId) { + try { + const res = await fetch(`${serverUrl}/cli/session/suggest`) + const { next } = await res.json() as { next: number } + console.error(`Error: -s/--session is required. Use -s ${next} for a new session.`) + } catch { + console.error(`Error: -s/--session is required. Use -s 1 for a new session.`) + } + process.exit(1) + } + + try { + const response = await fetch(`${serverUrl}/cli/reset`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId, cwd }), + }) + + if (!response.ok) { + const text = await response.text() + console.error(`Error: ${response.status} ${text}`) + process.exit(1) + } + + const result = await response.json() as { success: boolean; pageUrl: string; pagesCount: number } + console.log(`Connection reset successfully. ${result.pagesCount} page(s) available. Current page URL: ${result.pageUrl}`) + } catch (error: any) { + console.error(`Error: ${error.message}`) + process.exit(1) + } + }) + cli .command('serve', 'Start the CDP relay server for remote MCP connections') .option('--host ', 'Host to bind to', { default: '0.0.0.0' }) diff --git a/playwriter/src/executor.ts b/playwriter/src/executor.ts new file mode 100644 index 0000000..c314881 --- /dev/null +++ b/playwriter/src/executor.ts @@ -0,0 +1,683 @@ +/** + * PlaywrightExecutor - Manages browser connection and code execution per session. + * Used by both MCP and CLI to execute Playwright code with persistent state. + */ + +import { Page, Browser, BrowserContext, chromium } from 'playwright-core' +import crypto from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' +import os from 'node:os' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import vm from 'node:vm' +import { createPatch } from 'diff' +import { getCdpUrl } from './utils.js' +import { waitForPageLoad, WaitForPageLoadOptions, WaitForPageLoadResult } from './wait-for-page-load.js' +import { getCDPSessionForPage, CDPSession, ICDPSession } from './cdp-session.js' +import { Debugger } from './debugger.js' +import { Editor } from './editor.js' +import { getStylesForLocator, formatStylesAsText, type StylesResult } from './styles.js' +import { getReactSource, type ReactSourceLocation } from './react-source.js' +import { ScopedFS } from './scoped-fs.js' +import { screenshotWithAccessibilityLabels, type ScreenshotResult } from './aria-snapshot.js' +import { getCleanHTML, type GetCleanHTMLOptions } from './clean-html.js' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const require = createRequire(import.meta.url) + +export class CodeExecutionTimeoutError extends Error { + constructor(timeout: number) { + super(`Code execution timed out after ${timeout}ms`) + this.name = 'CodeExecutionTimeoutError' + } +} + +const usefulGlobals = { + setTimeout, + setInterval, + clearTimeout, + clearInterval, + URL, + URLSearchParams, + fetch, + Buffer, + TextEncoder, + TextDecoder, + crypto, + AbortController, + AbortSignal, + structuredClone, +} as const + +const NO_TABS_ERROR = `No browser tabs are connected. Please install and enable the Playwriter extension on at least one tab: https://chromewebstore.google.com/detail/playwriter-mcp/jfeammnjpkecdekppnclgkkffahnhfhe` + +const MAX_LOGS_PER_PAGE = 5000 + +const ALLOWED_MODULES = new Set([ + 'path', 'node:path', + 'url', 'node:url', + 'querystring', 'node:querystring', + 'punycode', 'node:punycode', + 'crypto', 'node:crypto', + 'buffer', 'node:buffer', + 'string_decoder', 'node:string_decoder', + 'util', 'node:util', + 'assert', 'node:assert', + 'events', 'node:events', + 'timers', 'node:timers', + 'stream', 'node:stream', + 'zlib', 'node:zlib', + 'http', 'node:http', + 'https', 'node:https', + 'http2', 'node:http2', + 'os', 'node:os', + 'fs', 'node:fs', +]) + +export interface ExecuteResult { + text: string + images: Array<{ data: string; mimeType: string }> + isError: boolean +} + +export interface ExecutorLogger { + log(...args: any[]): void + error(...args: any[]): void +} + +export interface CdpConfig { + host?: string + port?: number + token?: string +} + +export interface ExecutorOptions { + cdpConfig: CdpConfig + logger?: ExecutorLogger + /** Working directory for scoped fs access */ + cwd?: string +} + +function isRegExp(value: any): value is RegExp { + return ( + typeof value === 'object' && value !== null && typeof value.test === 'function' && typeof value.exec === 'function' + ) +} + +export class PlaywrightExecutor { + private isConnected = false + private page: Page | null = null + private browser: Browser | null = null + private context: BrowserContext | null = null + + private userState: Record = {} + private browserLogs: Map = new Map() + private lastSnapshots: WeakMap = new WeakMap() + private cdpSessionCache: WeakMap = new WeakMap() + private scopedFs: ScopedFS + private sandboxedRequire: NodeRequire + + private cdpConfig: CdpConfig + private logger: ExecutorLogger + + constructor(options: ExecutorOptions) { + this.cdpConfig = options.cdpConfig + this.logger = options.logger || { log: console.log, error: console.error } + // ScopedFS expects an array of allowed directories. If cwd is provided, use it; otherwise use defaults. + this.scopedFs = new ScopedFS(options.cwd ? [options.cwd, '/tmp', os.tmpdir()] : undefined) + this.sandboxedRequire = this.createSandboxedRequire(require) + } + + private createSandboxedRequire(originalRequire: NodeRequire): NodeRequire { + const scopedFs = this.scopedFs + const sandboxedRequire = ((id: string) => { + if (!ALLOWED_MODULES.has(id)) { + const error = new Error( + `Module "${id}" is not allowed in the sandbox. ` + + `Only safe Node.js built-ins are permitted: ${[...ALLOWED_MODULES].filter((m) => !m.startsWith('node:')).join(', ')}`, + ) + error.name = 'ModuleNotAllowedError' + throw error + } + if (id === 'fs' || id === 'node:fs') { + return scopedFs + } + return originalRequire(id) + }) as NodeRequire + + sandboxedRequire.resolve = originalRequire.resolve + sandboxedRequire.cache = originalRequire.cache + sandboxedRequire.extensions = originalRequire.extensions + sandboxedRequire.main = originalRequire.main + + return sandboxedRequire + } + + private async setDeviceScaleFactorForMacOS(context: BrowserContext): Promise { + if (os.platform() !== 'darwin') { + return + } + const options = (context as any)._options + if (!options || options.deviceScaleFactor === 2) { + return + } + options.deviceScaleFactor = 2 + } + + private async preserveSystemColorScheme(context: BrowserContext): Promise { + const options = (context as any)._options + if (!options) { + return + } + options.colorScheme = 'no-override' + options.reducedMotion = 'no-override' + options.forcedColors = 'no-override' + } + + private clearUserState() { + Object.keys(this.userState).forEach((key) => delete this.userState[key]) + } + + private clearConnectionState() { + this.isConnected = false + this.browser = null + this.page = null + this.context = null + } + + private setupPageConsoleListener(page: Page) { + const targetId = (page as any)._guid as string | undefined + if (!targetId) { + return + } + + if (!this.browserLogs.has(targetId)) { + this.browserLogs.set(targetId, []) + } + + page.on('framenavigated', (frame) => { + if (frame === page.mainFrame()) { + this.browserLogs.set(targetId, []) + } + }) + + page.on('close', () => { + this.browserLogs.delete(targetId) + }) + + page.on('console', (msg) => { + try { + const logEntry = `[${msg.type()}] ${msg.text()}` + if (!this.browserLogs.has(targetId)) { + this.browserLogs.set(targetId, []) + } + const pageLogs = this.browserLogs.get(targetId)! + pageLogs.push(logEntry) + if (pageLogs.length > MAX_LOGS_PER_PAGE) { + pageLogs.shift() + } + } catch (e) { + this.logger.error('[Executor] Failed to get console message text:', e) + } + }) + } + + private async ensureConnection(): Promise<{ browser: Browser; page: Page }> { + if (this.isConnected && this.browser && this.page) { + return { browser: this.browser, page: this.page } + } + + // Generate a fresh unique URL for each Playwright connection + const cdpUrl = getCdpUrl(this.cdpConfig) + const browser = await chromium.connectOverCDP(cdpUrl) + + browser.on('disconnected', () => { + this.logger.log('Browser disconnected, clearing connection state') + this.clearConnectionState() + }) + + const contexts = browser.contexts() + const context = contexts.length > 0 ? contexts[0] : await browser.newContext() + + context.on('page', (page) => { + this.setupPageConsoleListener(page) + }) + + const pages = context.pages() + if (pages.length === 0) { + throw new Error(NO_TABS_ERROR) + } + const page = pages[0] + + context.pages().forEach((p) => this.setupPageConsoleListener(p)) + + await this.preserveSystemColorScheme(context) + await this.setDeviceScaleFactorForMacOS(context) + + this.browser = browser + this.page = page + this.context = context + this.isConnected = true + + return { browser, page } + } + + private async getCurrentPage(timeout = 5000): Promise { + if (this.page && !this.page.isClosed()) { + return this.page + } + + if (this.browser) { + const contexts = this.browser.contexts() + if (contexts.length > 0) { + const pages = contexts[0].pages().filter((p) => !p.isClosed()) + if (pages.length > 0) { + const page = pages[0] + await page.waitForLoadState('domcontentloaded', { timeout }).catch(() => {}) + this.page = page + return page + } + } + } + + throw new Error(NO_TABS_ERROR) + } + + async reset(): Promise<{ page: Page; context: BrowserContext }> { + if (this.browser) { + try { + await this.browser.close() + } catch (e) { + this.logger.error('Error closing browser:', e) + } + } + + this.clearConnectionState() + this.clearUserState() + + // Generate a fresh unique URL for each Playwright connection + const cdpUrl = getCdpUrl(this.cdpConfig) + const browser = await chromium.connectOverCDP(cdpUrl) + + browser.on('disconnected', () => { + this.logger.log('Browser disconnected, clearing connection state') + this.clearConnectionState() + }) + + const contexts = browser.contexts() + const context = contexts.length > 0 ? contexts[0] : await browser.newContext() + + context.on('page', (page) => { + this.setupPageConsoleListener(page) + }) + + const pages = context.pages() + if (pages.length === 0) { + throw new Error(NO_TABS_ERROR) + } + const page = pages[0] + + context.pages().forEach((p) => this.setupPageConsoleListener(p)) + + await this.preserveSystemColorScheme(context) + await this.setDeviceScaleFactorForMacOS(context) + + this.browser = browser + this.page = page + this.context = context + this.isConnected = true + + return { page, context } + } + + async execute(code: string, timeout = 5000): Promise { + const consoleLogs: Array<{ method: string; args: any[] }> = [] + + const formatConsoleLogs = (logs: Array<{ method: string; args: any[] }>, prefix = 'Console output') => { + if (logs.length === 0) { + return '' + } + let text = `${prefix}:\n` + logs.forEach(({ method, args }) => { + const formattedArgs = args + .map((arg) => { + if (typeof arg === 'object') { + return JSON.stringify(arg, null, 2) + } + return String(arg) + }) + .join(' ') + text += `[${method}] ${formattedArgs}\n` + }) + return text + '\n' + } + + try { + await this.ensureConnection() + const page = await this.getCurrentPage(timeout) + const context = this.context || page.context() + + this.logger.log('Executing code:', code) + + const customConsole = { + log: (...args: any[]) => { consoleLogs.push({ method: 'log', args }) }, + info: (...args: any[]) => { consoleLogs.push({ method: 'info', args }) }, + warn: (...args: any[]) => { consoleLogs.push({ method: 'warn', args }) }, + error: (...args: any[]) => { consoleLogs.push({ method: 'error', args }) }, + debug: (...args: any[]) => { consoleLogs.push({ method: 'debug', args }) }, + } + + const accessibilitySnapshot = async (options: { + page: Page + search?: string | RegExp + showDiffSinceLastCall?: boolean + }) => { + const { page: targetPage, search, showDiffSinceLastCall = false } = options + if ((targetPage as any)._snapshotForAI) { + const snapshot = await (targetPage as any)._snapshotForAI() + const rawStr = typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot, null, 2) + const snapshotStr = rawStr.toWellFormed?.() ?? rawStr + + if (showDiffSinceLastCall) { + const previousSnapshot = this.lastSnapshots.get(targetPage) + if (!previousSnapshot) { + this.lastSnapshots.set(targetPage, snapshotStr) + return 'No previous snapshot available. This is the first call for this page. Full snapshot stored for next diff.' + } + const patch = createPatch('snapshot', previousSnapshot, snapshotStr, 'previous', 'current', { context: 3 }) + if (patch.split('\n').length <= 4) { + return 'No changes detected since last snapshot' + } + return patch + } + + this.lastSnapshots.set(targetPage, snapshotStr) + + if (!search) { + return snapshotStr + } + + const lines = snapshotStr.split('\n') + const matchIndices: number[] = [] + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + const isMatch = isRegExp(search) ? search.test(line) : line.includes(search) + if (isMatch) { + matchIndices.push(i) + if (matchIndices.length >= 10) break + } + } + + if (matchIndices.length === 0) { + return 'No matches found' + } + + const CONTEXT_LINES = 5 + const includedLines = new Set() + for (const idx of matchIndices) { + const start = Math.max(0, idx - CONTEXT_LINES) + const end = Math.min(lines.length - 1, idx + CONTEXT_LINES) + for (let i = start; i <= end; i++) { + includedLines.add(i) + } + } + + const sortedIndices = [...includedLines].sort((a, b) => a - b) + const result: string[] = [] + for (let i = 0; i < sortedIndices.length; i++) { + const lineIdx = sortedIndices[i] + if (i > 0 && sortedIndices[i - 1] !== lineIdx - 1) { + result.push('---') + } + result.push(lines[lineIdx]) + } + return result.join('\n') + } + throw new Error('accessibilitySnapshot is not available on this page') + } + + const getLocatorStringForElement = async (element: any) => { + if (!element || typeof element.evaluate !== 'function') { + throw new Error('getLocatorStringForElement: argument must be a Playwright Locator or ElementHandle') + } + const elementPage = element.page ? element.page() : page + const hasGenerator = await elementPage.evaluate(() => !!(globalThis as any).__selectorGenerator) + if (!hasGenerator) { + const scriptPath = path.join(__dirname, '..', 'dist', 'selector-generator.js') + const scriptContent = fs.readFileSync(scriptPath, 'utf-8') + const cdp = await getCDPSession({ page: elementPage }) + await cdp.send('Runtime.evaluate', { expression: scriptContent }) + } + return await element.evaluate((el: any) => { + const { createSelectorGenerator, toLocator } = (globalThis as any).__selectorGenerator + const generator = createSelectorGenerator(globalThis) + const result = generator(el) + return toLocator(result.selector, 'javascript') + }) + } + + const getPageTargetId = async (p: Page): Promise => { + const guid = (p as any)._guid + if (guid) return guid + throw new Error('Could not get page identifier: _guid not available') + } + + const getLatestLogs = async (options?: { page?: Page; count?: number; search?: string | RegExp }) => { + const { page: filterPage, count, search } = options || {} + let allLogs: string[] = [] + + if (filterPage) { + const targetId = await getPageTargetId(filterPage) + const pageLogs = this.browserLogs.get(targetId) || [] + allLogs = [...pageLogs] + } else { + for (const pageLogs of this.browserLogs.values()) { + allLogs.push(...pageLogs) + } + } + + if (search) { + const matchIndices: number[] = [] + for (let i = 0; i < allLogs.length; i++) { + const log = allLogs[i] + const isMatch = typeof search === 'string' ? log.includes(search) : isRegExp(search) && search.test(log) + if (isMatch) matchIndices.push(i) + } + + const CONTEXT_LINES = 5 + const includedIndices = new Set() + for (const idx of matchIndices) { + const start = Math.max(0, idx - CONTEXT_LINES) + const end = Math.min(allLogs.length - 1, idx + CONTEXT_LINES) + for (let i = start; i <= end; i++) { + includedIndices.add(i) + } + } + + const sortedIndices = [...includedIndices].sort((a, b) => a - b) + const result: string[] = [] + for (let i = 0; i < sortedIndices.length; i++) { + const logIdx = sortedIndices[i] + if (i > 0 && sortedIndices[i - 1] !== logIdx - 1) { + result.push('---') + } + result.push(allLogs[logIdx]) + } + allLogs = result + } + + return count !== undefined ? allLogs.slice(-count) : allLogs + } + + const clearAllLogs = () => { + this.browserLogs.clear() + } + + const getCDPSession = async (options: { page: Page }) => { + const cached = this.cdpSessionCache.get(options.page) + if (cached) return cached + // Generate a fresh unique URL for each CDP session to avoid client ID conflicts + const wsUrl = getCdpUrl(this.cdpConfig) + const session = await getCDPSessionForPage({ page: options.page, wsUrl }) + this.cdpSessionCache.set(options.page, session) + return session + } + + const createDebugger = (options: { cdp: ICDPSession }) => new Debugger(options) + const createEditor = (options: { cdp: ICDPSession }) => new Editor(options) + + const getStylesForLocatorFn = async (options: { locator: any }) => { + const cdp = await getCDPSession({ page: options.locator.page() }) + return getStylesForLocator({ locator: options.locator, cdp }) + } + + const getReactSourceFn = async (options: { locator: any }) => { + const cdp = await getCDPSession({ page: options.locator.page() }) + return getReactSource({ locator: options.locator, cdp }) + } + + const screenshotCollector: ScreenshotResult[] = [] + + const screenshotWithAccessibilityLabelsFn = async (options: { page: Page; interactiveOnly?: boolean }) => { + return screenshotWithAccessibilityLabels({ + ...options, + collector: screenshotCollector, + logger: { error: (...args) => { this.logger.error('[playwriter]', ...args) } }, + }) + } + + const self = this + + let vmContextObj: any = { + page, + context, + state: this.userState, + console: customConsole, + accessibilitySnapshot, + getCleanHTML, + getLocatorStringForElement, + getLatestLogs, + clearAllLogs, + waitForPageLoad, + getCDPSession, + createDebugger, + createEditor, + getStylesForLocator: getStylesForLocatorFn, + formatStylesAsText, + getReactSource: getReactSourceFn, + screenshotWithAccessibilityLabels: screenshotWithAccessibilityLabelsFn, + resetPlaywright: async () => { + const { page: newPage, context: newContext } = await self.reset() + vmContextObj.page = newPage + vmContextObj.context = newContext + return { page: newPage, context: newContext } + }, + require: this.sandboxedRequire, + import: (specifier: string) => import(specifier), + ...usefulGlobals, + } + + const vmContext = vm.createContext(vmContextObj) + const wrappedCode = `(async () => { ${code} })()` + + const result = await Promise.race([ + vm.runInContext(wrappedCode, vmContext, { timeout, displayErrors: true }), + new Promise((_, reject) => setTimeout(() => reject(new CodeExecutionTimeoutError(timeout)), timeout)), + ]) + + let responseText = formatConsoleLogs(consoleLogs) + + if (result !== undefined) { + responseText += 'Return value:\n' + if (typeof result === 'string') { + responseText += result + } else { + responseText += JSON.stringify(result, null, 2) + } + } else if (consoleLogs.length === 0) { + responseText += 'Code executed successfully (no output)' + } + + for (const screenshot of screenshotCollector) { + responseText += `\nScreenshot saved to: ${screenshot.path}\n` + responseText += `Labels shown: ${screenshot.labelCount}\n\n` + responseText += `Accessibility snapshot:\n${screenshot.snapshot}\n` + } + + const MAX_LENGTH = 6000 + let finalText = responseText.trim() + if (finalText.length > MAX_LENGTH) { + finalText = finalText.slice(0, MAX_LENGTH) + + `\n\n[Truncated to ${MAX_LENGTH} characters. Better manage your logs or paginate them to read the full logs]` + } + + const images = screenshotCollector.map((s) => ({ data: s.base64, mimeType: s.mimeType })) + + return { text: finalText, images, isError: false } + } catch (error: any) { + const errorStack = error.stack || error.message + const isTimeoutError = error instanceof CodeExecutionTimeoutError || error.name === 'TimeoutError' + + this.logger.error('Error in execute:', errorStack) + + const logsText = formatConsoleLogs(consoleLogs, 'Console output (before error)') + const resetHint = isTimeoutError ? '' : + '\n\n[HINT: If this is an internal Playwright error, page/browser closed, or connection issue, call reset to reconnect.]' + + return { + text: `${logsText}\nError executing code: ${error.message}\n${errorStack}${resetHint}`, + images: [], + isError: true, + } + } + } + + /** Get info about current connection state */ + getStatus(): { connected: boolean; pageUrl: string | null; pagesCount: number } { + return { + connected: this.isConnected, + pageUrl: this.page?.url() || null, + pagesCount: this.context?.pages().length || 0, + } + } +} + +/** + * Session manager for multiple executors, keyed by session ID (typically cwd hash) + */ +export class ExecutorManager { + private executors = new Map() + private cdpConfig: CdpConfig + private logger: ExecutorLogger + + constructor(options: { cdpConfig: CdpConfig; logger?: ExecutorLogger }) { + this.cdpConfig = options.cdpConfig + this.logger = options.logger || { log: console.log, error: console.error } + } + + getExecutor(sessionId: string, cwd?: string): PlaywrightExecutor { + let executor = this.executors.get(sessionId) + if (!executor) { + executor = new PlaywrightExecutor({ + cdpConfig: this.cdpConfig, + logger: this.logger, + cwd, + }) + this.executors.set(sessionId, executor) + } + return executor + } + + deleteExecutor(sessionId: string): boolean { + return this.executors.delete(sessionId) + } + + listSessions(): string[] { + return [...this.executors.keys()] + } +} + + diff --git a/playwriter/src/mcp.ts b/playwriter/src/mcp.ts index 991a230..ea2f58a 100644 --- a/playwriter/src/mcp.ts +++ b/playwriter/src/mcp.ts @@ -1,218 +1,21 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { z } from 'zod' -import { Page, Browser, BrowserContext, chromium } from 'playwright-core' -import crypto from 'node:crypto' import fs from 'node:fs' import path from 'node:path' -import os from 'node:os' -import { spawn } from 'node:child_process' -import { createRequire } from 'node:module' import { fileURLToPath } from 'node:url' -import vm from 'node:vm' +import { createRequire } from 'node:module' import dedent from 'string-dedent' -import { createPatch } from 'diff' -import { getCdpUrl, LOG_FILE_PATH, VERSION, sleep } from './utils.js' -import { killPortProcess } from 'kill-port-process' -import { waitForPageLoad, WaitForPageLoadOptions, WaitForPageLoadResult } from './wait-for-page-load.js' -import { getCDPSessionForPage, CDPSession, ICDPSession } from './cdp-session.js' -import { Debugger } from './debugger.js' -import { Editor } from './editor.js' -import { getStylesForLocator, formatStylesAsText, type StylesResult } from './styles.js' -import { getReactSource, type ReactSourceLocation } from './react-source.js' -import { ScopedFS } from './scoped-fs.js' -import { screenshotWithAccessibilityLabels, type ScreenshotResult } from './aria-snapshot.js' -import { getCleanHTML, type GetCleanHTMLOptions } from './clean-html.js' +import { LOG_FILE_PATH, VERSION } from './utils.js' +import { ensureRelayServer, RELAY_PORT } from './relay-client.js' +import { PlaywrightExecutor, CodeExecutionTimeoutError } from './executor.js' + const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) - -class CodeExecutionTimeoutError extends Error { - constructor(timeout: number) { - super(`Code execution timed out after ${timeout}ms`) - this.name = 'CodeExecutionTimeoutError' - } -} - const require = createRequire(import.meta.url) -const usefulGlobals = { - setTimeout, - setInterval, - clearTimeout, - clearInterval, - URL, - URLSearchParams, - fetch, - Buffer, - TextEncoder, - TextDecoder, - crypto, - AbortController, - AbortSignal, - structuredClone, -} as const - -interface State { - isConnected: boolean - page: Page | null - browser: Browser | null - context: BrowserContext | null -} - -interface VMContext { - page: Page - context: BrowserContext - state: Record - console: { - log: (...args: any[]) => void - info: (...args: any[]) => void - warn: (...args: any[]) => void - error: (...args: any[]) => void - debug: (...args: any[]) => void - } - accessibilitySnapshot: (options: { - page: Page - search?: string | RegExp - showDiffSinceLastCall?: boolean - }) => Promise - getCleanHTML: (options: GetCleanHTMLOptions) => Promise - getLocatorStringForElement: (element: any) => Promise - resetPlaywright: () => Promise<{ page: Page; context: BrowserContext }> - getLatestLogs: (options?: { page?: Page; count?: number; search?: string | RegExp }) => Promise - clearAllLogs: () => void - waitForPageLoad: (options: WaitForPageLoadOptions) => Promise - getCDPSession: (options: { page: Page }) => Promise - createDebugger: (options: { cdp: ICDPSession }) => Debugger - createEditor: (options: { cdp: ICDPSession }) => Editor - getStylesForLocator: (options: { locator: any }) => Promise - formatStylesAsText: (styles: StylesResult) => string - getReactSource: (options: { locator: any }) => Promise - screenshotWithAccessibilityLabels: (options: { page: Page; interactiveOnly?: boolean }) => Promise - require: NodeRequire - import: (specifier: string) => Promise -} - -type VMContextWithGlobals = VMContext & typeof usefulGlobals - -type SelectorGenerator = typeof import('@mizchi/selector-generator') - -const state: State = { - isConnected: false, - page: null, - browser: null, - context: null, -} - -const userState: Record = {} - -// Store logs per page targetId -const browserLogs: Map = new Map() -const MAX_LOGS_PER_PAGE = 5000 - -// Store last accessibility snapshot per page for diff feature -const lastSnapshots: WeakMap = new WeakMap() - -// Cache CDP sessions per page -const cdpSessionCache: WeakMap = new WeakMap() - -const RELAY_PORT = Number(process.env.PLAYWRITER_PORT) || 19988 -const NO_TABS_ERROR = `No browser tabs are connected. Please install and enable the Playwriter extension on at least one tab: https://chromewebstore.google.com/detail/playwriter-mcp/jfeammnjpkecdekppnclgkkffahnhfhe` - -// Create a scoped fs instance that allows access to cwd, /tmp, and os.tmpdir() -const scopedFs = new ScopedFS() - -/** - * Allowlist of Node.js built-in modules that are safe to use in the sandbox. - * Dangerous modules like child_process, cluster, worker_threads, vm, net are blocked. - */ -const ALLOWED_MODULES = new Set([ - // Safe utility modules - 'path', - 'node:path', - 'url', - 'node:url', - 'querystring', - 'node:querystring', - 'punycode', - 'node:punycode', - - // Crypto and encoding - 'crypto', - 'node:crypto', - 'buffer', - 'node:buffer', - 'string_decoder', - 'node:string_decoder', - - // Utilities - 'util', - 'node:util', - 'assert', - 'node:assert', - 'events', - 'node:events', - 'timers', - 'node:timers', - - // Streams and compression - 'stream', - 'node:stream', - 'zlib', - 'node:zlib', - - // HTTP (fetch is already available, these are consistent) - 'http', - 'node:http', - 'https', - 'node:https', - 'http2', - 'node:http2', - - // System info (read-only, useful for debugging) - 'os', - 'node:os', - - // fs is allowed but returns sandboxed version - 'fs', - 'node:fs', -]) - -/** - * Create a sandboxed require function that: - * 1. Returns scoped fs for 'fs' and 'node:fs' - * 2. Only allows modules in the ALLOWED_MODULES allowlist - * 3. Blocks all other modules (child_process, net, vm, third-party packages, etc.) - */ -function createSandboxedRequire(originalRequire: NodeRequire): NodeRequire { - const sandboxedRequire = ((id: string) => { - // Check allowlist first - if (!ALLOWED_MODULES.has(id)) { - const error = new Error( - `Module "${id}" is not allowed in the sandbox. ` + - `Only safe Node.js built-ins are permitted: ${[...ALLOWED_MODULES].filter((m) => !m.startsWith('node:')).join(', ')}`, - ) - error.name = 'ModuleNotAllowedError' - throw error - } - - // Return sandboxed fs - if (id === 'fs' || id === 'node:fs') { - return scopedFs - } - - return originalRequire(id) - }) as NodeRequire - - // Copy over require properties - sandboxedRequire.resolve = originalRequire.resolve - sandboxedRequire.cache = originalRequire.cache - sandboxedRequire.extensions = originalRequire.extensions - sandboxedRequire.main = originalRequire.main - - return sandboxedRequire -} - -const sandboxedRequire = createSandboxedRequire(require) +// Single executor instance for MCP (created lazily) +let executor: PlaywrightExecutor | null = null interface RemoteConfig { host: string @@ -232,44 +35,6 @@ function getRemoteConfig(): RemoteConfig | null { } } -async function setDeviceScaleFactorForMacOS(context: BrowserContext): Promise { - if (os.platform() !== 'darwin') { - return - } - const options = (context as any)._options - if (!options || options.deviceScaleFactor === 2) { - return - } - options.deviceScaleFactor = 2 -} - -async function preserveSystemColorScheme(context: BrowserContext): Promise { - const options = (context as any)._options - if (!options) { - return - } - options.colorScheme = 'no-override' - options.reducedMotion = 'no-override' - options.forcedColors = 'no-override' -} - -function isRegExp(value: any): value is RegExp { - return ( - typeof value === 'object' && value !== null && typeof value.test === 'function' && typeof value.exec === 'function' - ) -} - -function clearUserState() { - Object.keys(userState).forEach((key) => delete userState[key]) -} - -function clearConnectionState() { - state.isConnected = false - state.browser = null - state.page = null - state.context = null -} - function getLogServerUrl(): string { const remote = getRemoteConfig() if (remote) { @@ -300,810 +65,39 @@ function mcpLog(...args: any[]) { sendLogToRelayServer('log', ...args) } -async function getServerVersion(port: number): Promise { - try { - const response = await fetch(`http://127.0.0.1:${port}/version`, { - signal: AbortSignal.timeout(500), - }) - if (!response.ok) { - return null - } - const data = (await response.json()) as { version: string } - return data.version - } catch (error) { - return null - } +/** MCP-specific logger for executor */ +const mcpLogger = { + log: (...args: any[]) => mcpLog(...args), + error: (...args: any[]) => { + console.error(...args) + sendLogToRelayServer('error', ...args) + }, } -async function killRelayServer(port: number): Promise { - try { - await killPortProcess(port) - await sleep(500) - } catch {} +async function ensureRelayServerForMcp(): Promise { + await ensureRelayServer({ logger: mcpLogger }) } -/** - * Compare two semver versions. Returns: - * - negative if v1 < v2 - * - 0 if v1 === v2 - * - positive if v1 > v2 - */ -function compareVersions(v1: string, v2: string): number { - const parts1 = v1.split('.').map(Number) - const parts2 = v2.split('.').map(Number) - const len = Math.max(parts1.length, parts2.length) - - for (let i = 0; i < len; i++) { - const p1 = parts1[i] || 0 - const p2 = parts2[i] || 0 - if (p1 !== p2) { - return p1 - p2 - } +async function getOrCreateExecutor(): Promise { + if (executor) { + return executor } - return 0 -} - -async function ensureRelayServer(): Promise { - const serverVersion = await getServerVersion(RELAY_PORT) - - if (serverVersion === VERSION) { - return - } - - // Don't restart if server version is higher than MCP version. - // This prevents older MCPs from killing a newer server. - if (serverVersion !== null && compareVersions(serverVersion, VERSION) > 0) { - return - } - - if (serverVersion !== null) { - mcpLog(`CDP relay server version mismatch (server: ${serverVersion}, mcp: ${VERSION}), restarting...`) - await killRelayServer(RELAY_PORT) - } else { - mcpLog('CDP relay server not running, starting it...') - } - - const dev = process.env.PLAYWRITER_NODE_ENV === 'development' - const scriptPath = dev - ? path.resolve(__dirname, '../src/start-relay-server.ts') - : require.resolve('../dist/start-relay-server.js') - - const serverProcess = spawn(dev ? 'tsx' : process.execPath, [scriptPath], { - detached: true, - stdio: 'ignore', - env: { - ...process.env, - }, - }) - - serverProcess.unref() - - for (let i = 0; i < 10; i++) { - await sleep(500) - const newVersion = await getServerVersion(RELAY_PORT) - if (newVersion === VERSION) { - mcpLog('CDP relay server started successfully, waiting for extension to connect...') - await sleep(1000) - return - } - } - - throw new Error(`Failed to start CDP relay server after 5 seconds. Check logs at: ${LOG_FILE_PATH}`) -} - -async function ensureConnection(): Promise<{ browser: Browser; page: Page }> { - if (state.isConnected && state.browser && state.page) { - return { browser: state.browser, page: state.page } - } - + const remote = getRemoteConfig() if (!remote) { - await ensureRelayServer() + await ensureRelayServerForMcp() } - - const cdpEndpoint = getCdpUrl(remote || { port: RELAY_PORT }) - const browser = await chromium.connectOverCDP(cdpEndpoint) - - // Clear connection state when browser disconnects (e.g., extension reconnects, relay server restarts) - browser.on('disconnected', () => { - mcpLog('Browser disconnected, clearing connection state') - clearConnectionState() + + // Pass config instead of pre-generated URL so executor can generate unique URLs for each connection + const cdpConfig = remote || { port: RELAY_PORT } + executor = new PlaywrightExecutor({ + cdpConfig, + logger: mcpLogger, }) - - const contexts = browser.contexts() - const context = contexts.length > 0 ? contexts[0] : await browser.newContext() - - // Set up console listener for all future pages - context.on('page', (page) => { - setupPageConsoleListener(page) - }) - - const pages = context.pages() - if (pages.length === 0) { - throw new Error(NO_TABS_ERROR) - } - const page = pages[0] - - // Set up console listener for all existing pages - context.pages().forEach((p) => setupPageConsoleListener(p)) - - // These functions only set context-level options, they do NOT send CDP commands to pages. - // Sending CDP commands (like Emulation.setEmulatedMedia or setDeviceMetricsOverride) to pages - // immediately after connectOverCDP causes pages to render white/blank with about:blank URLs, - // because pages may not be fully initialized yet. Playwright applies these settings lazily. - await preserveSystemColorScheme(context) - await setDeviceScaleFactorForMacOS(context) - - state.browser = browser - state.page = page - state.context = context - state.isConnected = true - - return { browser, page } + + return executor } -async function getPageTargetId(page: Page): Promise { - if (!page) { - throw new Error('Page is null or undefined') - } - - const guid = (page as any)._guid - if (guid) { - return guid - } - - throw new Error('Could not get page identifier: _guid not available') -} - -function setupPageConsoleListener(page: Page) { - // Get targetId synchronously using _guid - const targetId = (page as any)._guid as string | undefined - - if (!targetId) { - // If no _guid, silently fail - this shouldn't happen in normal operation - return - } - - // Initialize logs array for this page - if (!browserLogs.has(targetId)) { - browserLogs.set(targetId, []) - } - - page.on('framenavigated', (frame) => { - if (frame === page.mainFrame()) { - browserLogs.set(targetId, []) - } - }) - - page.on('close', () => { - browserLogs.delete(targetId) - }) - - page.on('console', (msg) => { - try { - let logEntry = `[${msg.type()}] ${msg.text()}` - - if (!browserLogs.has(targetId)) { - browserLogs.set(targetId, []) - } - const pageLogs = browserLogs.get(targetId)! - - pageLogs.push(logEntry) - if (pageLogs.length > MAX_LOGS_PER_PAGE) { - pageLogs.shift() - } - } catch (e) { - mcpLog('[MCP] Failed to get console message text:', e) - return - } - }) -} - -async function getCurrentPage(timeout = 5000) { - // Check if current page is still valid (not closed) - if (state.page && !state.page.isClosed()) { - return state.page - } - - // Current page is closed or doesn't exist - find another available page - if (state.browser) { - const contexts = state.browser.contexts() - if (contexts.length > 0) { - const pages = contexts[0].pages().filter((p) => !p.isClosed()) - - if (pages.length > 0) { - const page = pages[0] - await page.waitForLoadState('domcontentloaded', { timeout }).catch(() => {}) - // Update state.page to the new default page - state.page = page - return page - } - } - } - - throw new Error(NO_TABS_ERROR) -} - -async function resetConnection(): Promise<{ browser: Browser; page: Page; context: BrowserContext }> { - if (state.browser) { - try { - await state.browser.close() - } catch (e) { - mcpLog('Error closing browser:', e) - } - } - - clearConnectionState() - clearUserState() - - // DO NOT clear browser logs on reset - logs should persist across reconnections - // browserLogs.clear() - - const remote = getRemoteConfig() - if (!remote) { - await ensureRelayServer() - } - - const cdpEndpoint = getCdpUrl(remote || { port: RELAY_PORT }) - const browser = await chromium.connectOverCDP(cdpEndpoint) - - // Clear connection state when browser disconnects (e.g., extension reconnects, relay server restarts) - browser.on('disconnected', () => { - mcpLog('Browser disconnected, clearing connection state') - clearConnectionState() - }) - - const contexts = browser.contexts() - const context = contexts.length > 0 ? contexts[0] : await browser.newContext() - - // Set up console listener for all future pages - context.on('page', (page) => { - setupPageConsoleListener(page) - }) - - const pages = context.pages() - if (pages.length === 0) { - throw new Error(NO_TABS_ERROR) - } - const page = pages[0] - - // Set up console listener for all existing pages - context.pages().forEach((p) => setupPageConsoleListener(p)) - - await preserveSystemColorScheme(context) - await setDeviceScaleFactorForMacOS(context) - - state.browser = browser - state.page = page - state.context = context - state.isConnected = true - - return { browser, page, context } -} - -const server = new McpServer({ - name: 'playwriter', - title: 'The better playwright MCP: works as a browser extension. No context bloat. More capable.', - version: '1.0.0', -}) - -const promptContent = - fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'prompt.md'), 'utf-8') + - `\n\nfor debugging internal playwriter errors, check playwriter relay server logs at: ${LOG_FILE_PATH}` - -server.resource( - 'debugger-api', - 'https://playwriter.dev/resources/debugger-api.md', - { mimeType: 'text/plain' }, - async () => { - const packageJsonPath = require.resolve('playwriter/package.json') - const packageDir = path.dirname(packageJsonPath) - const content = fs.readFileSync(path.join(packageDir, 'dist', 'debugger-api.md'), 'utf-8') - - return { - contents: [ - { - uri: 'https://playwriter.dev/resources/debugger-api.md', - text: content, - mimeType: 'text/plain', - }, - ], - } - }, -) - -server.resource( - 'editor-api', - 'https://playwriter.dev/resources/editor-api.md', - { mimeType: 'text/plain' }, - async () => { - const packageJsonPath = require.resolve('playwriter/package.json') - const packageDir = path.dirname(packageJsonPath) - const content = fs.readFileSync(path.join(packageDir, 'dist', 'editor-api.md'), 'utf-8') - - return { - contents: [ - { - uri: 'https://playwriter.dev/resources/editor-api.md', - text: content, - mimeType: 'text/plain', - }, - ], - } - }, -) - -server.resource( - 'styles-api', - 'https://playwriter.dev/resources/styles-api.md', - { mimeType: 'text/plain' }, - async () => { - const packageJsonPath = require.resolve('playwriter/package.json') - const packageDir = path.dirname(packageJsonPath) - const content = fs.readFileSync(path.join(packageDir, 'dist', 'styles-api.md'), 'utf-8') - - return { - contents: [ - { - uri: 'https://playwriter.dev/resources/styles-api.md', - text: content, - mimeType: 'text/plain', - }, - ], - } - }, -) - -server.tool( - 'execute', - promptContent, - { - code: z - .string() - .describe( - 'js playwright code, has {page, state, context} in scope. Should be one line, using ; to execute multiple statements. you MUST call execute multiple times instead of writing complex scripts in a single tool call.', - ), - timeout: z.number().default(5000).describe('Timeout in milliseconds for code execution (default: 5000ms)'), - }, - async ({ code, timeout }) => { - const consoleLogs: Array<{ method: string; args: any[] }> = [] - - const formatConsoleLogs = (logs: Array<{ method: string; args: any[] }>, prefix = 'Console output') => { - if (logs.length === 0) { - return '' - } - - let text = `${prefix}:\n` - logs.forEach(({ method, args }) => { - const formattedArgs = args - .map((arg) => { - if (typeof arg === 'object') { - return JSON.stringify(arg, null, 2) - } - return String(arg) - }) - .join(' ') - text += `[${method}] ${formattedArgs}\n` - }) - return text + '\n' - } - - try { - await ensureRelayServer() - await ensureConnection() - - const page = await getCurrentPage(timeout) - const context = state.context || page.context() - - mcpLog('Executing code:', code) - - const customConsole = { - log: (...args: any[]) => { - consoleLogs.push({ method: 'log', args }) - }, - info: (...args: any[]) => { - consoleLogs.push({ method: 'info', args }) - }, - warn: (...args: any[]) => { - consoleLogs.push({ method: 'warn', args }) - }, - error: (...args: any[]) => { - consoleLogs.push({ method: 'error', args }) - }, - debug: (...args: any[]) => { - consoleLogs.push({ method: 'debug', args }) - }, - } - - const accessibilitySnapshot = async (options: { - page: Page - search?: string | RegExp - showDiffSinceLastCall?: boolean - }) => { - const { page: targetPage, search, showDiffSinceLastCall = false } = options - if ((targetPage as any)._snapshotForAI) { - const snapshot = await (targetPage as any)._snapshotForAI() - // Sanitize to remove unpaired surrogates that break JSON encoding for Claude API - const rawStr = typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot, null, 2) - const snapshotStr = rawStr.toWellFormed?.() ?? rawStr - - if (showDiffSinceLastCall) { - const previousSnapshot = lastSnapshots.get(targetPage) - - if (!previousSnapshot) { - lastSnapshots.set(targetPage, snapshotStr) - return 'No previous snapshot available. This is the first call for this page. Full snapshot stored for next diff.' - } - - const patch = createPatch('snapshot', previousSnapshot, snapshotStr, 'previous', 'current', { - context: 3, - }) - if (patch.split('\n').length <= 4) { - return 'No changes detected since last snapshot' - } - return patch - } - - lastSnapshots.set(targetPage, snapshotStr) - - if (!search) { - return snapshotStr - } - - const lines = snapshotStr.split('\n') - const matchIndices: number[] = [] - - for (let i = 0; i < lines.length; i++) { - const line = lines[i] - let isMatch = false - if (isRegExp(search)) { - isMatch = search.test(line) - } else { - isMatch = line.includes(search) - } - - if (isMatch) { - matchIndices.push(i) - if (matchIndices.length >= 10) break - } - } - - if (matchIndices.length === 0) { - return 'No matches found' - } - - // Collect lines with 5 lines of context above and below each match - const CONTEXT_LINES = 5 - const includedLines = new Set() - for (const idx of matchIndices) { - const start = Math.max(0, idx - CONTEXT_LINES) - const end = Math.min(lines.length - 1, idx + CONTEXT_LINES) - for (let i = start; i <= end; i++) { - includedLines.add(i) - } - } - - // Build result with separators between non-contiguous sections - const sortedIndices = [...includedLines].sort((a, b) => a - b) - const result: string[] = [] - for (let i = 0; i < sortedIndices.length; i++) { - const lineIdx = sortedIndices[i] - if (i > 0 && sortedIndices[i - 1] !== lineIdx - 1) { - result.push('---') - } - result.push(lines[lineIdx]) - } - - return result.join('\n') - } - throw new Error('accessibilitySnapshot is not available on this page') - } - - const getLocatorStringForElement = async (element: any) => { - if (!element || typeof element.evaluate !== 'function') { - throw new Error('getLocatorStringForElement: argument must be a Playwright Locator or ElementHandle') - } - - const elementPage = element.page ? element.page() : page - const hasGenerator = await elementPage.evaluate(() => !!(globalThis as any).__selectorGenerator) - - if (!hasGenerator) { - const currentDir = path.dirname(fileURLToPath(import.meta.url)) - const scriptPath = path.join(currentDir, '..', 'dist', 'selector-generator.js') - const scriptContent = fs.readFileSync(scriptPath, 'utf-8') - const cdp = await getCDPSession({ page: elementPage }) - await cdp.send('Runtime.evaluate', { expression: scriptContent }) - } - - return await element.evaluate((el: any) => { - const { createSelectorGenerator, toLocator } = (globalThis as any).__selectorGenerator - const generator = createSelectorGenerator(globalThis) - const result = generator(el) - return toLocator(result.selector, 'javascript') - }) - } - - const getLatestLogs = async (options?: { page?: Page; count?: number; search?: string | RegExp }) => { - const { page: filterPage, count, search } = options || {} - - let allLogs: string[] = [] - - if (filterPage) { - const targetId = await getPageTargetId(filterPage) - const pageLogs = browserLogs.get(targetId) || [] - allLogs = [...pageLogs] - } else { - for (const pageLogs of browserLogs.values()) { - allLogs.push(...pageLogs) - } - } - - if (search) { - const matchIndices: number[] = [] - for (let i = 0; i < allLogs.length; i++) { - const log = allLogs[i] - let isMatch = false - if (typeof search === 'string') { - isMatch = log.includes(search) - } else if (isRegExp(search)) { - isMatch = search.test(log) - } - if (isMatch) { - matchIndices.push(i) - } - } - - // Collect logs with 5 lines of context above and below each match - const CONTEXT_LINES = 5 - const includedIndices = new Set() - for (const idx of matchIndices) { - const start = Math.max(0, idx - CONTEXT_LINES) - const end = Math.min(allLogs.length - 1, idx + CONTEXT_LINES) - for (let i = start; i <= end; i++) { - includedIndices.add(i) - } - } - - // Build result with separators between non-contiguous sections - const sortedIndices = [...includedIndices].sort((a, b) => a - b) - const result: string[] = [] - for (let i = 0; i < sortedIndices.length; i++) { - const logIdx = sortedIndices[i] - if (i > 0 && sortedIndices[i - 1] !== logIdx - 1) { - result.push('---') - } - result.push(allLogs[logIdx]) - } - - allLogs = result - } - - return count !== undefined ? allLogs.slice(-count) : allLogs - } - - const clearAllLogs = () => { - browserLogs.clear() - } - - const getCDPSession = async (options: { page: Page }) => { - const cached = cdpSessionCache.get(options.page) - if (cached) { - return cached - } - const wsUrl = getCdpUrl(getRemoteConfig() || { port: RELAY_PORT }) - const session = await getCDPSessionForPage({ page: options.page, wsUrl }) - cdpSessionCache.set(options.page, session) - return session - } - - const createDebugger = (options: { cdp: ICDPSession }) => { - return new Debugger(options) - } - - const createEditor = (options: { cdp: ICDPSession }) => { - return new Editor(options) - } - - const getStylesForLocatorFn = async (options: { locator: any }) => { - const cdp = await getCDPSession({ page: options.locator.page() }) - return getStylesForLocator({ locator: options.locator, cdp }) - } - - const getReactSourceFn = async (options: { locator: any }) => { - const cdp = await getCDPSession({ page: options.locator.page() }) - return getReactSource({ locator: options.locator, cdp }) - } - - // Collector for screenshots taken during this execution - const screenshotCollector: ScreenshotResult[] = [] - - const screenshotWithAccessibilityLabelsFn = async (options: { page: Page; interactiveOnly?: boolean }) => { - return screenshotWithAccessibilityLabels({ - ...options, - collector: screenshotCollector, - logger: { error: (...args) => { console.error('[playwriter]', ...args) } }, - }) - } - - let vmContextObj: VMContextWithGlobals = { - page, - context, - state: userState, - console: customConsole, - accessibilitySnapshot, - getCleanHTML, - getLocatorStringForElement, - getLatestLogs, - clearAllLogs, - waitForPageLoad, - getCDPSession, - createDebugger, - createEditor, - getStylesForLocator: getStylesForLocatorFn, - formatStylesAsText, - getReactSource: getReactSourceFn, - screenshotWithAccessibilityLabels: screenshotWithAccessibilityLabelsFn, - resetPlaywright: async () => { - const { page: newPage, context: newContext } = await resetConnection() - - const resetObj: VMContextWithGlobals = { - page: newPage, - context: newContext, - state: userState, - console: customConsole, - accessibilitySnapshot, - getCleanHTML, - getLocatorStringForElement, - getLatestLogs, - clearAllLogs, - waitForPageLoad, - getCDPSession, - createDebugger, - createEditor, - getStylesForLocator: getStylesForLocatorFn, - formatStylesAsText, - getReactSource: getReactSourceFn, - screenshotWithAccessibilityLabels: screenshotWithAccessibilityLabelsFn, - resetPlaywright: vmContextObj.resetPlaywright, - require: sandboxedRequire, - // TODO --experimental-vm-modules is needed to make import work in vm - import: vmContextObj.import, - ...usefulGlobals, - } - Object.keys(vmContextObj).forEach((key) => delete (vmContextObj as any)[key]) - Object.assign(vmContextObj, resetObj) - return { page: newPage, context: newContext } - }, - require: sandboxedRequire, - import: (specifier: string) => import(specifier), - ...usefulGlobals, - } - - const vmContext = vm.createContext(vmContextObj) - - const wrappedCode = `(async () => { ${code} })()` - - const result = await Promise.race([ - vm.runInContext(wrappedCode, vmContext, { - timeout, - displayErrors: true, - }), - new Promise((_, reject) => setTimeout(() => reject(new CodeExecutionTimeoutError(timeout)), timeout)), - ]) - - let responseText = formatConsoleLogs(consoleLogs) - - if (result !== undefined) { - responseText += 'Return value:\n' - if (typeof result === 'string') { - responseText += result - } else { - responseText += JSON.stringify(result, null, 2) - } - } else if (consoleLogs.length === 0) { - responseText += 'Code executed successfully (no output)' - } - - // Add screenshot info to response text - for (const screenshot of screenshotCollector) { - responseText += `\nScreenshot saved to: ${screenshot.path}\n` - responseText += `Labels shown: ${screenshot.labelCount}\n\n` - responseText += `Accessibility snapshot:\n${screenshot.snapshot}\n` - } - - const MAX_LENGTH = 6000 - let finalText = responseText.trim() - if (finalText.length > MAX_LENGTH) { - finalText = - finalText.slice(0, MAX_LENGTH) + - `\n\n[Truncated to ${MAX_LENGTH} characters. Better manage your logs or paginate them to read the full logs]` - } - - // Build content array with text and any collected screenshots - const content: Array<{ type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }> = [ - { - type: 'text', - text: finalText, - }, - ] - - // Add all collected screenshots as images - for (const screenshot of screenshotCollector) { - content.push({ - type: 'image', - data: screenshot.base64, - mimeType: screenshot.mimeType, - }) - } - - return { content } - } catch (error: any) { - const errorStack = error.stack || error.message - const isTimeoutError = error instanceof CodeExecutionTimeoutError || error.name === 'TimeoutError' - - // Always log to stderr, but only send non-timeout errors to relay server - console.error('Error in execute tool:', errorStack) - if (!isTimeoutError) { - sendLogToRelayServer('error', 'Error in execute tool:', errorStack) - } - - const logsText = formatConsoleLogs(consoleLogs, 'Console output (before error)') - - const resetHint = isTimeoutError - ? '' - : '\n\n[HINT: If this is an internal Playwright error, page/browser closed, or connection issue, call the `reset` tool to reconnect. Do NOT reset for other non-connection non-internal errors.]' - - return { - content: [ - { - type: 'text', - text: `${logsText}\nError executing code: ${error.message}\n${errorStack}${resetHint}`, - }, - ], - isError: true, - } - } - }, -) - -server.tool( - 'reset', - dedent` - Recreates the CDP connection and resets the browser/page/context. Use this when the MCP stops responding, you get connection errors, if there are no pages in context, assertion failures, page closed, or other issues. - - After calling this tool, the page and context variables are automatically updated in the execution environment. - - This tools also removes any custom properties you may have added to the global scope AND clearing all keys from the \`state\` object. Only \`page\`, \`context\`, \`state\` (empty), \`console\`, and utility functions will remain. - - if playwright always returns all pages as about:blank urls and evaluate does not work you should ask the user to restart Chrome. This is a known Chrome bug. - `, - {}, - async () => { - try { - const { page, context } = await resetConnection() - const pagesCount = context.pages().length - return { - content: [ - { - type: 'text', - text: `Connection reset successfully. ${pagesCount} page(s) available. Current page URL: ${page.url()}`, - }, - ], - } - } catch (error: any) { - return { - content: [ - { - type: 'text', - text: `Failed to reset connection: ${error.message}`, - }, - ], - isError: true, - } - } - }, -) - async function checkRemoteServer({ host, port }: { host: string; port: number }): Promise { const versionUrl = `http://${host}:${port}/version` try { @@ -1123,6 +117,140 @@ async function checkRemoteServer({ host, port }: { host: string; port: number }) } } +const server = new McpServer({ + name: 'playwriter', + title: 'The better playwright MCP: works as a browser extension. No context bloat. More capable.', + version: VERSION, +}) + +const promptContent = + fs.readFileSync(path.join(__dirname, '..', 'src', 'prompt.md'), 'utf-8') + + `\n\nfor debugging internal playwriter errors, check playwriter relay server logs at: ${LOG_FILE_PATH}` + +server.resource( + 'debugger-api', + 'https://playwriter.dev/resources/debugger-api.md', + { mimeType: 'text/plain' }, + async () => { + const packageJsonPath = require.resolve('playwriter/package.json') + const packageDir = path.dirname(packageJsonPath) + const content = fs.readFileSync(path.join(packageDir, 'dist', 'debugger-api.md'), 'utf-8') + return { + contents: [{ uri: 'https://playwriter.dev/resources/debugger-api.md', text: content, mimeType: 'text/plain' }], + } + }, +) + +server.resource( + 'editor-api', + 'https://playwriter.dev/resources/editor-api.md', + { mimeType: 'text/plain' }, + async () => { + const packageJsonPath = require.resolve('playwriter/package.json') + const packageDir = path.dirname(packageJsonPath) + const content = fs.readFileSync(path.join(packageDir, 'dist', 'editor-api.md'), 'utf-8') + return { + contents: [{ uri: 'https://playwriter.dev/resources/editor-api.md', text: content, mimeType: 'text/plain' }], + } + }, +) + +server.resource( + 'styles-api', + 'https://playwriter.dev/resources/styles-api.md', + { mimeType: 'text/plain' }, + async () => { + const packageJsonPath = require.resolve('playwriter/package.json') + const packageDir = path.dirname(packageJsonPath) + const content = fs.readFileSync(path.join(packageDir, 'dist', 'styles-api.md'), 'utf-8') + return { + contents: [{ uri: 'https://playwriter.dev/resources/styles-api.md', text: content, mimeType: 'text/plain' }], + } + }, +) + +server.tool( + 'execute', + promptContent, + { + code: z + .string() + .describe( + 'js playwright code, has {page, state, context} in scope. Should be one line, using ; to execute multiple statements. you MUST call execute multiple times instead of writing complex scripts in a single tool call.', + ), + timeout: z.number().default(5000).describe('Timeout in milliseconds for code execution (default: 5000ms)'), + }, + async ({ code, timeout }) => { + try { + const exec = await getOrCreateExecutor() + const result = await exec.execute(code, timeout) + + // Transform executor result to MCP format + const content: Array<{ type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }> = [ + { type: 'text', text: result.text }, + ] + + for (const image of result.images) { + content.push({ type: 'image', data: image.data, mimeType: image.mimeType }) + } + + if (result.isError) { + return { content, isError: true } + } + + return { content } + } catch (error: any) { + const errorStack = error.stack || error.message + const isTimeoutError = error instanceof CodeExecutionTimeoutError || error.name === 'TimeoutError' + + console.error('Error in execute tool:', errorStack) + if (!isTimeoutError) { + sendLogToRelayServer('error', 'Error in execute tool:', errorStack) + } + + const resetHint = isTimeoutError + ? '' + : '\n\n[HINT: If this is an internal Playwright error, page/browser closed, or connection issue, call the `reset` tool to reconnect. Do NOT reset for other non-connection non-internal errors.]' + + return { + content: [{ type: 'text', text: `Error executing code: ${error.message}\n${errorStack}${resetHint}` }], + isError: true, + } + } + }, +) + +server.tool( + 'reset', + dedent` + Recreates the CDP connection and resets the browser/page/context. Use this when the MCP stops responding, you get connection errors, if there are no pages in context, assertion failures, page closed, or other issues. + + After calling this tool, the page and context variables are automatically updated in the execution environment. + + This tools also removes any custom properties you may have added to the global scope AND clearing all keys from the \`state\` object. Only \`page\`, \`context\`, \`state\` (empty), \`console\`, and utility functions will remain. + + if playwright always returns all pages as about:blank urls and evaluate does not work you should ask the user to restart Chrome. This is a known Chrome bug. + `, + {}, + async () => { + try { + const exec = await getOrCreateExecutor() + const { page, context } = await exec.reset() + const pagesCount = context.pages().length + return { + content: [ + { type: 'text', text: `Connection reset successfully. ${pagesCount} page(s) available. Current page URL: ${page.url()}` }, + ], + } + } catch (error: any) { + return { + content: [{ type: 'text', text: `Failed to reset connection: ${error.message}` }], + isError: true, + } + } + }, +) + export async function startMcp(options: { host?: string; token?: string } = {}) { if (options.host) { process.env.PLAYWRITER_HOST = options.host @@ -1133,7 +261,7 @@ export async function startMcp(options: { host?: string; token?: string } = {}) const remote = getRemoteConfig() if (!remote) { - await ensureRelayServer() + await ensureRelayServerForMcp() } else { mcpLog(`Using remote CDP relay server: ${remote.host}:${remote.port}`) await checkRemoteServer(remote) diff --git a/playwriter/src/relay-client.ts b/playwriter/src/relay-client.ts new file mode 100644 index 0000000..c345ece --- /dev/null +++ b/playwriter/src/relay-client.ts @@ -0,0 +1,122 @@ +/** + * Shared utilities for connecting to the relay server. + * Used by both MCP and CLI. + */ + +import { spawn } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { killPortProcess } from 'kill-port-process' +import { VERSION, sleep, LOG_FILE_PATH } from './utils.js' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +export const RELAY_PORT = Number(process.env.PLAYWRITER_PORT) || 19988 + +export async function getRelayServerVersion(port: number = RELAY_PORT): Promise { + try { + const response = await fetch(`http://127.0.0.1:${port}/version`, { + signal: AbortSignal.timeout(500), + }) + if (!response.ok) { + return null + } + const data = (await response.json()) as { version: string } + return data.version + } catch { + return null + } +} + +async function killRelayServer(port: number): Promise { + try { + await killPortProcess(port) + await sleep(500) + } catch {} +} + +/** + * Compare two semver versions. Returns: + * - negative if v1 < v2 + * - 0 if v1 === v2 + * - positive if v1 > v2 + */ +function compareVersions(v1: string, v2: string): number { + const parts1 = v1.split('.').map(Number) + const parts2 = v2.split('.').map(Number) + const len = Math.max(parts1.length, parts2.length) + + for (let i = 0; i < len; i++) { + const p1 = parts1[i] || 0 + const p2 = parts2[i] || 0 + if (p1 !== p2) { + return p1 - p2 + } + } + return 0 +} + +export interface EnsureRelayServerOptions { + /** Optional logger for status messages */ + logger?: { + log: (...args: any[]) => void + } + /** If true, will kill and restart server on version mismatch. Default: true */ + restartOnVersionMismatch?: boolean +} + +/** + * Ensures the relay server is running. Starts it if not running. + * Optionally restarts on version mismatch. + */ +export async function ensureRelayServer(options: EnsureRelayServerOptions = {}): Promise { + const { logger, restartOnVersionMismatch = true } = options + const serverVersion = await getRelayServerVersion(RELAY_PORT) + + if (serverVersion === VERSION) { + return + } + + // Don't restart if server version is higher than our version. + // This prevents older clients from killing a newer server. + if (serverVersion !== null && compareVersions(serverVersion, VERSION) > 0) { + return + } + + if (serverVersion !== null) { + if (restartOnVersionMismatch) { + logger?.log(`CDP relay server version mismatch (server: ${serverVersion}, client: ${VERSION}), restarting...`) + await killRelayServer(RELAY_PORT) + } else { + // Server is running but different version, just use it + return + } + } else { + logger?.log('CDP relay server not running, starting it...') + } + + const dev = process.env.PLAYWRITER_NODE_ENV === 'development' + const scriptPath = dev + ? path.resolve(__dirname, '../src/start-relay-server.ts') + : path.resolve(__dirname, './start-relay-server.js') + + const serverProcess = spawn(dev ? 'tsx' : process.execPath, [scriptPath], { + detached: true, + stdio: 'ignore', + env: { ...process.env }, + }) + + serverProcess.unref() + + for (let i = 0; i < 10; i++) { + await sleep(500) + const newVersion = await getRelayServerVersion(RELAY_PORT) + if (newVersion) { + logger?.log('CDP relay server started successfully') + return + } + } + + throw new Error(`Failed to start CDP relay server after 5 seconds. Check logs at: ${LOG_FILE_PATH}`) +}