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:
Tommy D. Rossi
2026-01-22 19:48:43 +01:00
parent 58dc932990
commit 67dd9aaef4
5 changed files with 1192 additions and 1039 deletions
+78
View File
@@ -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
View File
@@ -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' })
+683
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+122
View File
@@ -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}`)
}