/** * PlaywrightExecutor - Manages browser connection and code execution per session. * Used by both MCP and CLI to execute Playwright code with persistent state. */ import { chromium } from '@xmorse/playwright-core'; import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import util from 'node:util'; import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import vm from 'node:vm'; import * as acorn from 'acorn'; import { createSmartDiff } from './diff-utils.js'; import { getCdpUrl, parseRelayHost } from './utils.js'; import { getExtensionOutdatedWarning } from './relay-client.js'; import { waitForPageLoad } from './wait-for-page-load.js'; import { getCDPSessionForPage } from './cdp-session.js'; import { Debugger } from './debugger.js'; import { Editor } from './editor.js'; import { getStylesForLocator, formatStylesAsText } from './styles.js'; import { getReactSource } from './react-source.js'; import { ScopedFS } from './scoped-fs.js'; import { screenshotWithAccessibilityLabels, getAriaSnapshot, resizeImage, } from './aria-snapshot.js'; import { createGhostBrowserChrome } from './ghost-browser.js'; import { getCleanHTML } from './clean-html.js'; import { getPageMarkdown } from './page-markdown.js'; import { createRecordingApi } from './screen-recording.js'; import { createDemoVideo } from './ffmpeg.js'; import { RecordingGhostCursorController } from './recording-ghost-cursor.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) { 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, }; /** * Parse code and check if it's a single expression that should be auto-returned. * Returns the exact expression source (without trailing semicolon) using AST * node offsets, or null if the code should not be auto-wrapped. See #58. */ export function getAutoReturnExpression(code) { try { const ast = acorn.parse(code, { ecmaVersion: 'latest', allowAwaitOutsideFunction: true, allowReturnOutsideFunction: true, sourceType: 'script', }); // Must be exactly one statement if (ast.body.length !== 1) { return null; } const stmt = ast.body[0]; // If it's already a return statement, don't auto-wrap if (stmt.type === 'ReturnStatement') { return null; } // Must be an ExpressionStatement if (stmt.type !== 'ExpressionStatement') { return null; } // Don't auto-return side-effect expressions const expr = stmt.expression; if (expr.type === 'AssignmentExpression' || expr.type === 'UpdateExpression' || (expr.type === 'UnaryExpression' && expr.operator === 'delete')) { return null; } // Don't auto-return sequence expressions that contain assignments if (expr.type === 'SequenceExpression') { const hasAssignment = expr.expressions.some((e) => e.type === 'AssignmentExpression'); if (hasAssignment) { return null; } } // Use the expression node's start/end offsets to extract just the expression // source, excluding any trailing semicolon. This is more robust than regex. return code.slice(expr.start, expr.end); } catch { // Parse failed, don't auto-return return null; } } /** Backward-compatible helper: returns true if code should be auto-wrapped. */ export function shouldAutoReturn(code) { return getAutoReturnExpression(code) !== null; } /** * Wraps user code in an async IIFE for vm execution. * Uses AST node offsets to extract the expression without trailing semicolons, * avoiding SyntaxError when embedding inside `return await (...)`. See #58. */ export function wrapCode(code) { const expr = getAutoReturnExpression(code); if (expr !== null) { return `(async () => { return await (${expr}) })()`; } return `(async () => { ${code} })()`; } const EXTENSION_NOT_CONNECTED_ERROR = `The Playwriter Chrome extension is not connected. Make sure you have: 1. Installed the extension: https://chromewebstore.google.com/detail/playwriter-mcp/jfeammnjpkecdekppnclgkkffahnhfhe 2. Clicked the extension icon on a tab to enable it (or refreshed the page if just installed)`; const NO_PAGES_AVAILABLE_ERROR = 'No Playwright pages are available. Enable Playwriter on a tab or set PLAYWRITER_AUTO_ENABLE=1 to auto-create one.'; 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', ]); function isRegExp(value) { return (typeof value === 'object' && value !== null && typeof value.test === 'function' && typeof value.exec === 'function'); } function isPromise(value) { return typeof value === 'object' && value !== null && typeof value.then === 'function'; } export class PlaywrightExecutor { isConnected = false; page = null; browser = null; context = null; userState = {}; browserLogs = new Map(); lastSnapshots = new WeakMap(); lastRefToLocator = new WeakMap(); warningEvents = []; nextWarningEventId = 0; lastDeliveredWarningEventId = 0; // Recording timestamp tracking: when recording is active, each execute() // call pushes {start, end} (seconds relative to recordingStartedAt). // Returned by stopRecording() so the model can speed up idle sections. recordingStartedAt = null; executionTimestamps = []; activeWarningScopes = new Set(); pagesWithListeners = new WeakSet(); suppressPageCloseWarnings = false; scopedFs; sandboxedRequire; cdpConfig; logger; sessionMetadata; hasWarnedExtensionOutdated = false; constructor(options) { this.cdpConfig = options.cdpConfig; this.logger = options.logger || { log: console.log, error: console.error }; this.sessionMetadata = options.sessionMetadata || { extensionId: null, browser: null, profile: null }; // 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); } createSandboxedRequire(originalRequire) { const scopedFs = this.scopedFs; const sandboxedRequire = ((id) => { 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); }); sandboxedRequire.resolve = originalRequire.resolve; sandboxedRequire.cache = originalRequire.cache; sandboxedRequire.extensions = originalRequire.extensions; sandboxedRequire.main = originalRequire.main; return sandboxedRequire; } async setDeviceScaleFactorForMacOS(context) { if (os.platform() !== 'darwin') { return; } const options = context._options; if (!options || options.deviceScaleFactor === 2) { return; } options.deviceScaleFactor = 2; } clearUserState() { Object.keys(this.userState).forEach((key) => delete this.userState[key]); } clearConnectionState() { this.isConnected = false; this.browser = null; this.page = null; this.context = null; } enqueueWarning(message) { this.nextWarningEventId += 1; this.warningEvents.push({ id: this.nextWarningEventId, message }); } beginWarningScope() { const scope = { cursor: this.nextWarningEventId, }; this.activeWarningScopes.add(scope); return scope; } flushWarningsForScope(scope) { const relevantWarnings = this.warningEvents.filter((warning) => { return warning.id > scope.cursor; }); const latestWarningId = relevantWarnings.at(-1)?.id; if (latestWarningId && latestWarningId > this.lastDeliveredWarningEventId) { this.lastDeliveredWarningEventId = latestWarningId; } this.activeWarningScopes.delete(scope); this.pruneDeliveredWarnings(); if (relevantWarnings.length === 0) { return ''; } return `${relevantWarnings.map((warning) => `[WARNING] ${warning.message}`).join('\n')}\n`; } pruneDeliveredWarnings() { const activeCursors = [...this.activeWarningScopes].map((scope) => { return scope.cursor; }); const minActiveCursor = activeCursors.length > 0 ? Math.min(...activeCursors) : this.lastDeliveredWarningEventId; const pruneBeforeOrAt = Math.min(this.lastDeliveredWarningEventId, minActiveCursor); this.warningEvents = this.warningEvents.filter((warning) => { return warning.id > pruneBeforeOrAt; }); } warnIfExtensionOutdated(playwriterVersion) { if (this.hasWarnedExtensionOutdated) { return; } const warning = getExtensionOutdatedWarning(playwriterVersion); if (warning) { this.logger.log(warning); this.hasWarnedExtensionOutdated = true; } } setupPageListeners(page) { if (this.pagesWithListeners.has(page)) { return; } this.pagesWithListeners.add(page); this.setupPageCloseDetection(page); this.setupPageConsoleListener(page); this.setupPopupDetection(page); } setupPageCloseDetection(page) { page.on('close', () => { const stateKeysForClosedPage = Object.entries(this.userState) .filter(([, value]) => { return value === page; }) .map(([key]) => key); const wasCurrentPage = this.page === page; let replacementPageInfo = null; if (wasCurrentPage) { this.page = null; const context = this.context || page.context(); const openPages = context.pages().filter((candidate) => { return !candidate.isClosed(); }); if (openPages.length > 0) { const replacementPage = openPages[0]; this.page = replacementPage; const replacementIndex = context.pages().indexOf(replacementPage); replacementPageInfo = { index: replacementIndex >= 0 ? String(replacementIndex) : 'unknown', url: replacementPage.url() || 'unknown', }; } } if (!this.isConnected || this.suppressPageCloseWarnings || stateKeysForClosedPage.length === 0) { return; } const stateKeyLabel = stateKeysForClosedPage.map((key) => `state.${key}`).join(', '); const closedUrl = page.url() || 'unknown'; if (!wasCurrentPage) { this.enqueueWarning(`Page closed (url: ${closedUrl}) for ${stateKeyLabel}. ` + `Assign a new open page to ${stateKeyLabel} before reusing it.`); return; } if (replacementPageInfo) { this.enqueueWarning(`The current page in ${stateKeyLabel} was closed (url: ${closedUrl}). ` + `Switched active page to index ${replacementPageInfo.index} (url: ${replacementPageInfo.url}). ` + `Reassign ${stateKeyLabel} before using it again.`); return; } this.enqueueWarning(`The current page in ${stateKeyLabel} was closed (url: ${closedUrl}). ` + `No open pages remain. Open a tab with Playwriter enabled, then reassign ${stateKeyLabel}.`); }); } setupPopupDetection(page) { // Listen for popup events (window.open, target=_blank) on each page. // This is more reliable than checking page.opener() on context 'page' event, // which also fires for context.newPage() and CDP reconnection scenarios. page.on('popup', (popup) => { const context = page.context(); const pages = context.pages(); const rawIndex = pages.indexOf(popup); const pageIndex = rawIndex >= 0 ? String(rawIndex) : 'unknown'; const url = popup.url(); this.enqueueWarning(`Popup window detected (page index ${pageIndex}, url: ${url}). ` + `Popup windows cannot be controlled by playwriter. ` + `Repeat the interaction in a way that does not open a popup, or navigate to the URL directly in a new tab.`); }); } setupPageConsoleListener(page) { // Use targetId() if available, fallback to internal _guid for CDP connections const targetId = page.targetId() || page._guid; 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); } }); } async checkExtensionStatus() { const { host = '127.0.0.1', port = 19988, extensionId } = this.cdpConfig; const { httpBaseUrl } = parseRelayHost(host, port); const notConnected = { connected: false, activeTargets: 0, playwriterVersion: null }; try { if (extensionId) { const response = await fetch(`${httpBaseUrl}/extensions/status`, { signal: AbortSignal.timeout(2000), }); if (!response.ok) { const fallback = await fetch(`${httpBaseUrl}/extension/status`, { signal: AbortSignal.timeout(2000), }); if (!fallback.ok) { return notConnected; } return (await fallback.json()); } const data = (await response.json()); const extension = data.extensions.find((item) => { return item.extensionId === extensionId || item.stableKey === extensionId; }); if (!extension) { return notConnected; } return { connected: true, activeTargets: extension.activeTargets, playwriterVersion: extension?.playwriterVersion || null, }; } const response = await fetch(`${httpBaseUrl}/extension/status`, { signal: AbortSignal.timeout(2000), }); if (!response.ok) { return notConnected; } return (await response.json()); } catch { return notConnected; } } async ensureConnection() { if (this.isConnected && this.browser && this.page) { return { browser: this.browser, page: this.page }; } // Check extension status first to provide better error messages const extensionStatus = await this.checkExtensionStatus(); if (!extensionStatus.connected) { throw new Error(EXTENSION_NOT_CONNECTED_ERROR); } this.warnIfExtensionOutdated(extensionStatus.playwriterVersion); // 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(); // Action timeout (click, fill, hover, etc.) is short for fast agent failure. // Navigation timeout (goto, reload) is longer since page loads are slower. context.setDefaultTimeout(2000); context.setDefaultNavigationTimeout(10000); context.on('page', (page) => { this.setupPageListeners(page); }); context.pages().forEach((p) => this.setupPageListeners(p)); const page = await this.ensurePageForContext({ context, timeout: 10000 }); await this.setDeviceScaleFactorForMacOS(context); this.browser = browser; this.page = page; this.context = context; this.isConnected = true; return { browser, page }; } async getCurrentPage(timeout = 10000) { if (this.page && !this.page.isClosed()) { return this.page; } if (this.browser) { const contexts = this.browser.contexts(); if (contexts.length > 0) { const context = contexts[0]; this.context = context; const pages = context.pages().filter((p) => !p.isClosed()); if (pages.length > 0) { const page = pages[0]; await page.waitForLoadState('domcontentloaded', { timeout }).catch(() => { }); this.page = page; return page; } const page = await this.ensurePageForContext({ context, timeout }); this.page = page; return page; } } throw new Error(NO_PAGES_AVAILABLE_ERROR); } async reset() { if (this.browser) { this.suppressPageCloseWarnings = true; try { await this.browser.close(); } catch (e) { this.logger.error('Error closing browser:', e); } finally { this.suppressPageCloseWarnings = false; } } this.clearConnectionState(); this.clearUserState(); // Check extension status first to provide better error messages const extensionStatus = await this.checkExtensionStatus(); if (!extensionStatus.connected) { throw new Error(EXTENSION_NOT_CONNECTED_ERROR); } this.warnIfExtensionOutdated(extensionStatus.playwriterVersion); // 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(); // Action timeout (click, fill, hover, etc.) is short for fast agent failure. // Navigation timeout (goto, reload) is longer since page loads are slower. context.setDefaultTimeout(2000); context.setDefaultNavigationTimeout(10000); context.on('page', (page) => { this.setupPageListeners(page); }); context.pages().forEach((p) => this.setupPageListeners(p)); const page = await this.ensurePageForContext({ context, timeout: 10000 }); await this.setDeviceScaleFactorForMacOS(context); this.browser = browser; this.page = page; this.context = context; this.isConnected = true; return { page, context }; } async execute(code, timeout = 10000) { const consoleLogs = []; const warningScope = this.beginWarningScope(); const formatConsoleLogs = (logs, prefix = 'Console output') => { if (logs.length === 0) { return ''; } let text = `${prefix}:\n`; logs.forEach(({ method, args }) => { const formattedArgs = args .map((arg) => { if (typeof arg === 'string') return arg; return util.inspect(arg, { depth: 4, colors: false, maxArrayLength: 100, maxStringLength: 1000, breakLength: 80, }); }) .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) => { consoleLogs.push({ method: 'log', args }); }, info: (...args) => { consoleLogs.push({ method: 'info', args }); }, warn: (...args) => { consoleLogs.push({ method: 'warn', args }); }, error: (...args) => { consoleLogs.push({ method: 'error', args }); }, debug: (...args) => { consoleLogs.push({ method: 'debug', args }); }, }; const snapshot = async (options) => { const { page: targetPage, frame, locator, search, showDiffSinceLastCall = !search, interactiveOnly = false, } = options; const resolvedPage = targetPage || page; if (!resolvedPage) { throw new Error('snapshot requires a page'); } // Use new in-page implementation via getAriaSnapshot const { snapshot: rawSnapshot, refs, getSelectorForRef, } = await getAriaSnapshot({ page: resolvedPage, frame, locator, interactiveOnly, }); const snapshotStr = rawSnapshot.toWellFormed?.() ?? rawSnapshot; const refToLocator = new Map(); for (const entry of refs) { const locatorStr = getSelectorForRef(entry.ref); if (locatorStr) { refToLocator.set(entry.shortRef, locatorStr); } } this.lastRefToLocator.set(resolvedPage, refToLocator); const shouldCacheSnapshot = !frame; // Cache keyed by locator selector so full-page and locator-scoped snapshots // don't pollute each other's diff baselines const snapshotKey = locator ? `locator:${locator.selector()}` : 'page'; let pageSnapshots = this.lastSnapshots.get(resolvedPage); if (!pageSnapshots) { pageSnapshots = new Map(); this.lastSnapshots.set(resolvedPage, pageSnapshots); } const previousSnapshot = shouldCacheSnapshot ? pageSnapshots.get(snapshotKey) : undefined; if (shouldCacheSnapshot) { pageSnapshots.set(snapshotKey, snapshotStr); } // Diff defaults off when search is provided, but agent can explicitly enable both if (showDiffSinceLastCall && previousSnapshot && shouldCacheSnapshot) { const diffResult = createSmartDiff({ oldContent: previousSnapshot, newContent: snapshotStr, label: 'snapshot', }); if (diffResult.type === 'no-change') { return 'No changes since last snapshot. Use showDiffSinceLastCall: false to see full content.'; } return diffResult.content; } if (!search) { return `${snapshotStr}\n\nuse refToLocator({ ref: 'e3' }) to get locators for ref strings.`; } const lines = snapshotStr.split('\n'); const matchIndices = []; 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 = []; 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'); }; const refToLocator = (options) => { const targetPage = options.page || page; const map = this.lastRefToLocator.get(targetPage); if (!map) { return null; } return map.get(options.ref) ?? null; }; const getLocatorStringForElement = async (element) => { 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.__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) => { const { createSelectorGenerator, toLocator } = globalThis.__selectorGenerator; const generator = createSelectorGenerator(globalThis); const result = generator(el); return toLocator(result.selector, 'javascript'); }); }; const getLatestLogs = async (options) => { const { page: filterPage, count, search } = options || {}; let allLogs = []; if (filterPage) { // Use targetId() if available, fallback to internal _guid for CDP connections const targetId = filterPage.targetId() || filterPage._guid; if (!targetId) { throw new Error('Could not get page targetId'); } const pageLogs = this.browserLogs.get(targetId) || []; allLogs = [...pageLogs]; } else { for (const pageLogs of this.browserLogs.values()) { allLogs.push(...pageLogs); } } if (search) { const matchIndices = []; 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 = []; 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) => { if (options.page.isClosed()) { throw new Error('Cannot create CDP session for closed page'); } return await getCDPSessionForPage({ page: options.page }); }; const createDebugger = (options) => new Debugger(options); const createEditor = (options) => new Editor(options); const getStylesForLocatorFn = async (options) => { const cdp = await getCDPSession({ page: options.locator.page() }); return getStylesForLocator({ locator: options.locator, cdp }); }; const getReactSourceFn = async (options) => { const cdp = await getCDPSession({ page: options.locator.page() }); return getReactSource({ locator: options.locator, cdp }); }; const screenshotCollector = []; const screenshotWithAccessibilityLabelsFn = async (options) => { return screenshotWithAccessibilityLabels({ ...options, collector: screenshotCollector, logger: { info: (...args) => { this.logger.error('[playwriter]', ...args); }, error: (...args) => { this.logger.error('[playwriter]', ...args); }, }, }); }; // Screen recording functions (via chrome.tabCapture in extension - survives navigation) // Recording uses chrome.tabCapture which requires activeTab permission. // This permission is granted when the user clicks the Playwriter extension icon on a tab. const relayPort = this.cdpConfig.port || 19988; const self = this; const recordingGhostCursor = new RecordingGhostCursorController({ logger: { error: (...args) => { self.logger.error(...args); }, }, }); const showGhostCursor = async (options) => { const targetPage = options?.page || page; const cursorOptions = (() => { if (!options) { return undefined; } const { page: _ignoredPage, ...rest } = options; return rest; })(); await recordingGhostCursor.show({ page: targetPage, cursorOptions }); }; const hideGhostCursor = async (options) => { const targetPage = options?.page || page; await recordingGhostCursor.hide({ page: targetPage }); }; const recordingApi = createRecordingApi({ context, defaultPage: page, relayPort, ghostCursorController: recordingGhostCursor, onStart: () => { self.recordingStartedAt = Date.now(); self.executionTimestamps = []; }, onFinish: () => { self.recordingStartedAt = null; self.executionTimestamps = []; }, getExecutionTimestamps: () => { return self.executionTimestamps; }, }); // Ghost Browser API - creates chrome object that mirrors Ghost Browser's APIs // See extension/src/ghost-browser-api.d.ts for full API documentation const chromeGhostBrowser = createGhostBrowserChrome(async (namespace, method, args) => { const cdp = await getCDPSession({ page }); const result = await cdp.send('ghost-browser', { namespace, method, args }); const typed = result; if (!typed.success) { throw new Error(typed.error || `Ghost Browser API call failed: ${namespace}.${method}`); } return typed.result; }); let vmContextObj = { page, context, state: this.userState, console: customConsole, snapshot, accessibilitySnapshot: snapshot, // backward compat alias refToLocator, getCleanHTML, getPageMarkdown, getLocatorStringForElement, getLatestLogs, clearAllLogs, waitForPageLoad, getCDPSession, createDebugger, createEditor, getStylesForLocator: getStylesForLocatorFn, formatStylesAsText, getReactSource: getReactSourceFn, screenshotWithAccessibilityLabels: screenshotWithAccessibilityLabelsFn, resizeImage, ghostCursor: { show: showGhostCursor, hide: hideGhostCursor, }, recording: { start: recordingApi.start, stop: recordingApi.stop, isRecording: recordingApi.isRecording, cancel: recordingApi.cancel, }, // Backward-compatible aliases startRecording: recordingApi.start, stopRecording: recordingApi.stop, isRecording: recordingApi.isRecording, cancelRecording: recordingApi.cancel, createDemoVideo, 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) => import(specifier), // Ghost Browser API - only works in Ghost Browser, mirrors chrome.ghostPublicAPI etc chrome: chromeGhostBrowser, ...usefulGlobals, }; const vmContext = vm.createContext(vmContextObj); const autoReturnExpr = getAutoReturnExpression(code); const wrappedCode = autoReturnExpr !== null ? `(async () => { return await (${autoReturnExpr}) })()` : `(async () => { ${code} })()`; const hasExplicitReturn = autoReturnExpr !== null || /\breturn\b/.test(code); // Track execution timestamps relative to recording start (seconds). // Used to identify idle gaps that can be sped up in demo videos. // Captured before execution so we can record timing even if it throws. const recordingStartSnapshot = this.recordingStartedAt; const execStartSec = recordingStartSnapshot !== null ? (Date.now() - recordingStartSnapshot) / 1000 : -1; const result = await (async () => { try { return await Promise.race([ vm.runInContext(wrappedCode, vmContext, { timeout, displayErrors: true }), new Promise((_, reject) => setTimeout(() => reject(new CodeExecutionTimeoutError(timeout)), timeout)), ]); } finally { // Record timestamp even on error — the execution still occupied real time // that should not be sped up in the demo video. // Compare against snapshot to avoid cross-session contamination if // recording was stopped and restarted inside the same execute() call. if (recordingStartSnapshot !== null && execStartSec >= 0 && this.recordingStartedAt === recordingStartSnapshot) { const execEndSec = (Date.now() - recordingStartSnapshot) / 1000; this.executionTimestamps.push({ start: execStartSec, end: execEndSec }); } } })(); let responseText = formatConsoleLogs(consoleLogs); // Only show return value if user explicitly used return if (hasExplicitReturn) { const resolvedResult = isPromise(result) ? await result : result; if (resolvedResult !== undefined) { const formatted = typeof resolvedResult === 'string' ? resolvedResult : util.inspect(resolvedResult, { depth: 4, colors: false, maxArrayLength: 100, maxStringLength: 1000, breakLength: 80, }); if (formatted.trim()) { responseText += `[return value] ${formatted}\n`; } } } responseText += this.flushWarningsForScope(warningScope); if (!responseText.trim()) { 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 = 10000; let finalText = responseText.trim(); if (finalText.length > MAX_LENGTH) { finalText = finalText.slice(0, MAX_LENGTH) + `\n\n[Truncated to ${MAX_LENGTH} characters. Use search to find specific content]`; } const images = screenshotCollector.map((s) => ({ data: s.base64, mimeType: s.mimeType })); return { text: finalText, images, isError: false }; } catch (error) { const errorStack = error.stack || error.message; const isTimeoutError = error instanceof CodeExecutionTimeoutError || error?.name === 'TimeoutError' || error?.name === 'AbortError'; this.logger.error('Error in execute:', errorStack); const logsText = formatConsoleLogs(consoleLogs, 'Console output (before error)'); const warningText = this.flushWarningsForScope(warningScope); const resetHint = isTimeoutError ? '' : '\n\n[HINT: If this is an internal Playwright error, page/browser closed, or connection issue, call reset to reconnect.]'; // timeout stacks are internal noise (Promise.race / setTimeout); only show the message const errorText = isTimeoutError ? error.message : errorStack; return { text: `${logsText}${warningText}\nError executing code: ${errorText}${resetHint}`, images: [], isError: true, }; } } // When extension is connected but has no pages, auto-create only if PLAYWRITER_AUTO_ENABLE is set. async ensurePageForContext(options) { const { context, timeout } = options; const pages = context.pages().filter((p) => !p.isClosed()); if (pages.length > 0) { return pages[0]; } const extensionStatus = await this.checkExtensionStatus(); if (!extensionStatus.connected) { throw new Error(EXTENSION_NOT_CONNECTED_ERROR); } if (!process.env.PLAYWRITER_AUTO_ENABLE) { const waitTimeoutMs = Math.min(timeout, 1000); const startTime = Date.now(); while (Date.now() - startTime < waitTimeoutMs) { const availablePages = context.pages().filter((p) => !p.isClosed()); if (availablePages.length > 0) { return availablePages[0]; } await new Promise((resolve) => setTimeout(resolve, 100)); } throw new Error(NO_PAGES_AVAILABLE_ERROR); } const page = await context.newPage(); this.setupPageListeners(page); const pageUrl = page.url(); if (pageUrl === 'about:blank') { return page; } // Avoid burning the full timeout on about:blank-like pages. await page.waitForLoadState('domcontentloaded', { timeout }).catch(() => { }); return page; } /** Get info about current connection state */ getStatus() { return { connected: this.isConnected, pageUrl: this.page?.url() || null, pagesCount: this.context?.pages().length || 0, }; } /** Get keys of user-defined state */ getStateKeys() { return Object.keys(this.userState); } getSessionMetadata() { return this.sessionMetadata; } } /** * Session manager for multiple executors, keyed by session ID (typically cwd hash) */ export class ExecutorManager { executors = new Map(); cdpConfig; logger; constructor(options) { this.cdpConfig = options.cdpConfig; this.logger = options.logger || { log: console.log, error: console.error }; } getExecutor(options) { const { sessionId, cwd, sessionMetadata } = options; let executor = this.executors.get(sessionId); if (!executor) { const baseConfig = typeof this.cdpConfig === 'function' ? this.cdpConfig(sessionId) : this.cdpConfig; const cdpConfig = sessionMetadata?.extensionId ? { ...baseConfig, extensionId: sessionMetadata.extensionId } : baseConfig; executor = new PlaywrightExecutor({ cdpConfig, sessionMetadata, logger: this.logger, cwd, }); this.executors.set(sessionId, executor); } return executor; } deleteExecutor(sessionId) { return this.executors.delete(sessionId); } getSession(sessionId) { return this.executors.get(sessionId) || null; } listSessions() { return [...this.executors.entries()].map(([id, executor]) => { const metadata = executor.getSessionMetadata(); return { id, stateKeys: executor.getStateKeys(), extensionId: metadata.extensionId, browser: metadata.browser, profile: metadata.profile, }; }); } } //# sourceMappingURL=executor.js.map