feat: add -e/--eval CLI flag for code execution
- Add -e/--eval flag to execute JavaScript code via relay server - Extract PlaywrightExecutor class to shared executor.ts module - Extract relay server utilities to relay-client.ts - Add /cli/execute, /cli/reset, /cli/sessions endpoints - Fix CDP session client ID collision by using fresh unique URLs for each connection
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
+145
-3
@@ -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 <host>', 'Remote relay server host to connect to (or use PLAYWRITER_HOST env var)')
|
||||
.option('--token <token>', 'Authentication token (or use PLAYWRITER_TOKEN env var)')
|
||||
.action(async (options: { host?: string; token?: string }) => {
|
||||
.option('-e, --eval <code>', 'Execute JavaScript code and exit')
|
||||
.option('--timeout <ms>', 'Execution timeout in milliseconds', { default: 5000 })
|
||||
.option('-s, --session <name>', '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<void> {
|
||||
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 <name>', 'Session name (required)')
|
||||
.option('--host <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>', 'Host to bind to', { default: '0.0.0.0' })
|
||||
|
||||
@@ -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<string, any> = {}
|
||||
private browserLogs: Map<string, string[]> = new Map()
|
||||
private lastSnapshots: WeakMap<Page, string> = new WeakMap()
|
||||
private cdpSessionCache: WeakMap<Page, CDPSession> = 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<void> {
|
||||
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<void> {
|
||||
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<Page> {
|
||||
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<ExecuteResult> {
|
||||
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<number>()
|
||||
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<string> => {
|
||||
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<number>()
|
||||
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<string, PlaywrightExecutor>()
|
||||
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()]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+164
-1036
File diff suppressed because it is too large
Load Diff
@@ -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<string | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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}`)
|
||||
}
|
||||
Reference in New Issue
Block a user