1069 lines
46 KiB
JavaScript
1069 lines
46 KiB
JavaScript
/**
|
|
* 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
|