From c2d13c0363a43793f738bb5f8e4556476e336282 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Fri, 26 Dec 2025 00:01:05 +0100 Subject: [PATCH] add editor. use different port in tests --- extension/src/background.ts | 11 +- extension/vite.config.mts | 4 +- playwriter/src/debugger-examples-types.ts | 2 + playwriter/src/editor-examples.ts | 104 +++++++ playwriter/src/editor.ts | 341 ++++++++++++++++++++++ playwriter/src/mcp-client.ts | 21 +- playwriter/src/mcp.test.ts | 162 +++++++--- playwriter/src/mcp.ts | 47 ++- playwriter/src/prompt.md | 28 +- 9 files changed, 658 insertions(+), 62 deletions(-) create mode 100644 playwriter/src/editor-examples.ts create mode 100644 playwriter/src/editor.ts diff --git a/extension/src/background.ts b/extension/src/background.ts index add4f4e..db75392 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -1,9 +1,12 @@ +declare const process: { env: { PLAYWRITER_PORT: string } } + import { createStore } from 'zustand/vanilla' import type { ExtensionState, ConnectionState, TabState, TabInfo } from './types' import type { CDPEvent, Protocol } from 'playwriter/src/cdp-types' import type { ExtensionCommandMessage, ExtensionResponseMessage } from 'playwriter/src/extension/protocol' -const RELAY_URL = 'ws://localhost:19988/extension' +const RELAY_PORT = process.env.PLAYWRITER_PORT +const RELAY_URL = `ws://localhost:${RELAY_PORT}/extension` function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) @@ -462,7 +465,7 @@ function startReplacedRetryLoop(): void { return } try { - const response = await fetch('http://localhost:19988/extension/status') + const response = await fetch(`http://localhost:${RELAY_PORT}/extension/status`) const data = await response.json() if (!data.connected) { store.setState({ connectionState: 'disconnected', errorText: undefined }) @@ -518,12 +521,12 @@ async function ensureConnection(): Promise { return } - logger.debug('Waiting for server at http://localhost:19988...') + logger.debug(`Waiting for server at http://localhost:${RELAY_PORT}...`) store.setState({ connectionState: 'reconnecting' }) while (true) { try { - await fetch('http://localhost:19988', { method: 'HEAD' }) + await fetch(`http://localhost:${RELAY_PORT}`, { method: 'HEAD' }) logger.debug('Server is available') break } catch { diff --git a/extension/vite.config.mts b/extension/vite.config.mts index c3ef963..888185d 100644 --- a/extension/vite.config.mts +++ b/extension/vite.config.mts @@ -7,7 +7,9 @@ import fs from 'node:fs'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -const defineEnv: Record = {}; +const defineEnv: Record = { + 'process.env.PLAYWRITER_PORT': JSON.stringify(process.env.PLAYWRITER_PORT || '19988'), +}; if (process.env.TESTING) { defineEnv['import.meta.env.TESTING'] = 'true'; } diff --git a/playwriter/src/debugger-examples-types.ts b/playwriter/src/debugger-examples-types.ts index 5b445ea..9ddc8fa 100644 --- a/playwriter/src/debugger-examples-types.ts +++ b/playwriter/src/debugger-examples-types.ts @@ -1,8 +1,10 @@ import type { Page } from 'playwright-core' import type { CDPSession } from './cdp-session.js' import type { Debugger } from './debugger.js' +import type { Editor } from './editor.js' export declare const page: Page export declare const getCDPSession: (options: { page: Page }) => Promise export declare const createDebugger: (options: { cdp: CDPSession }) => Debugger +export declare const createEditor: (options: { cdp: CDPSession }) => Editor export declare const console: { log: (...args: unknown[]) => void } diff --git a/playwriter/src/editor-examples.ts b/playwriter/src/editor-examples.ts new file mode 100644 index 0000000..cc9e95d --- /dev/null +++ b/playwriter/src/editor-examples.ts @@ -0,0 +1,104 @@ +import { page, getCDPSession, createEditor, console } from './debugger-examples-types.js' + +// Example: List available scripts +async function listScripts() { + const cdp = await getCDPSession({ page }) + const editor = createEditor({ cdp }) + await editor.enable() + + const scripts = editor.list({ search: 'app' }) + console.log(scripts) +} + +// Example: Read a script with line numbers +async function readScript() { + const cdp = await getCDPSession({ page }) + const editor = createEditor({ cdp }) + await editor.enable() + + const { content, totalLines } = await editor.read({ + url: 'https://example.com/app.js', + }) + console.log('Total lines:', totalLines) + console.log(content) + + const { content: partial } = await editor.read({ + url: 'https://example.com/app.js', + offset: 100, + limit: 50, + }) + console.log(partial) +} + +// Example: Edit a script (exact string replacement) +async function editScript() { + const cdp = await getCDPSession({ page }) + const editor = createEditor({ cdp }) + await editor.enable() + + await editor.edit({ + url: 'https://example.com/app.js', + oldString: 'const DEBUG = false', + newString: 'const DEBUG = true', + }) + + const dryRunResult = await editor.edit({ + url: 'https://example.com/app.js', + oldString: 'old code', + newString: 'new code', + dryRun: true, + }) + console.log('Dry run result:', dryRunResult) +} + +// Example: Search across all scripts +async function searchScripts() { + const cdp = await getCDPSession({ page }) + const editor = createEditor({ cdp }) + await editor.enable() + + const matches = await editor.grep({ regex: /console\.log/ }) + console.log(matches) + + const todoMatches = await editor.grep({ + regex: /TODO|FIXME/i, + include: 'app', + }) + console.log(todoMatches) +} + +// Example: Write entire script content +async function writeScript() { + const cdp = await getCDPSession({ page }) + const editor = createEditor({ cdp }) + await editor.enable() + + const { content } = await editor.read({ url: 'https://example.com/app.js' }) + const newContent = content.replace(/console\.log/g, 'console.debug') + + await editor.write({ + url: 'https://example.com/app.js', + content: newContent, + }) +} + +// Example: Edit an inline script (scripts without URL get inline://{id} URLs) +async function editInlineScript() { + const cdp = await getCDPSession({ page }) + const editor = createEditor({ cdp }) + await editor.enable() + + const matches = await editor.grep({ regex: /myFunction/ }) + if (matches.length > 0) { + const { url } = matches[0] + console.log('Found in:', url) + + await editor.edit({ + url, + oldString: 'return false', + newString: 'return true', + }) + } +} + +export { listScripts, readScript, editScript, searchScripts, writeScript, editInlineScript } diff --git a/playwriter/src/editor.ts b/playwriter/src/editor.ts new file mode 100644 index 0000000..a2d526f --- /dev/null +++ b/playwriter/src/editor.ts @@ -0,0 +1,341 @@ +import type { CDPSession } from './cdp-session.js' + +export interface ScriptInfo { + url: string + scriptId: string +} + +export interface ReadResult { + content: string + totalLines: number + startLine: number + endLine: number +} + +export interface SearchMatch { + url: string + lineNumber: number + lineContent: string +} + +export interface EditResult { + success: boolean + stackChanged?: boolean +} + +/** + * A class for viewing and editing web page scripts via Chrome DevTools Protocol. + * Provides a Claude Code-like interface: list, read, edit, grep. + * + * @example + * ```ts + * const cdp = await getCDPSession({ page }) + * const editor = new Editor({ cdp }) + * await editor.enable() + * + * // List available scripts + * const scripts = editor.list({ search: 'app' }) + * + * // Read a script + * const { content } = await editor.read({ url: 'https://example.com/app.js' }) + * + * // Edit a script + * await editor.edit({ + * url: 'https://example.com/app.js', + * oldString: 'console.log("old")', + * newString: 'console.log("new")' + * }) + * ``` + */ +export class Editor { + private cdp: CDPSession + private enabled = false + private scripts = new Map() + private scriptsByUrl = new Map() + private sourceCache = new Map() + + constructor({ cdp }: { cdp: CDPSession }) { + this.cdp = cdp + this.setupEventListeners() + } + + private setupEventListeners() { + this.cdp.on('Debugger.scriptParsed', (params) => { + if (!params.url.startsWith('chrome') && !params.url.startsWith('devtools')) { + const url = params.url || `inline://${params.scriptId}` + const info: ScriptInfo = { + url, + scriptId: params.scriptId, + } + this.scripts.set(params.scriptId, info) + this.scriptsByUrl.set(url, info) + this.sourceCache.delete(params.scriptId) + } + }) + } + + /** + * Enables the editor. Must be called before other methods. + * Scripts are collected from Debugger.scriptParsed events. + * Reload the page after enabling to capture all scripts. + */ + async enable(): Promise { + if (this.enabled) { + return + } + await this.cdp.send('Debugger.enable') + this.enabled = true + } + + private getScriptByUrl(url: string): ScriptInfo { + const script = this.scriptsByUrl.get(url) + if (!script) { + const available = Array.from(this.scriptsByUrl.keys()).slice(0, 5) + throw new Error(`Script not found: ${url}\nAvailable: ${available.join(', ')}${this.scriptsByUrl.size > 5 ? '...' : ''}`) + } + return script + } + + /** + * Lists available scripts. Use search to filter by URL substring. + * + * @param options - Options + * @param options.search - Optional substring to filter URLs (case-insensitive) + * @returns Array of scripts with url and scriptId + * + * @example + * ```ts + * // List all scripts + * const scripts = editor.list() + * + * // Search for specific scripts + * const appScripts = editor.list({ search: 'app' }) + * const reactScripts = editor.list({ search: 'react' }) + * ``` + */ + list({ search }: { search?: string } = {}): ScriptInfo[] { + const scripts = Array.from(this.scripts.values()) + const filtered = search ? scripts.filter((s) => s.url.toLowerCase().includes(search.toLowerCase())) : scripts + return filtered + } + + /** + * Reads a script's source code by URL. + * Returns line-numbered content like Claude Code's Read tool. + * For inline scripts, use the `inline://` URL from list() or grep(). + * + * @param options - Options + * @param options.url - Script URL (inline scripts have `inline://{id}` URLs) + * @param options.offset - Line number to start from (0-based, default 0) + * @param options.limit - Number of lines to return (default 2000) + * @returns Content with line numbers, total lines, and range info + * + * @example + * ```ts + * // Read by URL + * const { content, totalLines } = await editor.read({ + * url: 'https://example.com/app.js' + * }) + * + * // Read inline script (URL from grep result) + * const { content } = await editor.read({ url: 'inline://42' }) + * + * // Read lines 100-200 + * const { content } = await editor.read({ + * url: 'https://example.com/app.js', + * offset: 100, + * limit: 100 + * }) + * ``` + */ + async read({ url, offset = 0, limit = 2000 }: { url: string; offset?: number; limit?: number }): Promise { + await this.enable() + const script = this.getScriptByUrl(url) + + let source = this.sourceCache.get(script.scriptId) + if (!source) { + const response = await this.cdp.send('Debugger.getScriptSource', { scriptId: script.scriptId }) + source = response.scriptSource + this.sourceCache.set(script.scriptId, source) + } + + const lines = source.split('\n') + const totalLines = lines.length + const startLine = Math.min(offset, totalLines) + const endLine = Math.min(offset + limit, totalLines) + const selectedLines = lines.slice(startLine, endLine) + + const content = selectedLines.map((line, i) => `${String(startLine + i + 1).padStart(5)}| ${line}`).join('\n') + + return { + content, + totalLines, + startLine: startLine + 1, + endLine, + } + } + + /** + * Edits a script by replacing oldString with newString. + * Like Claude Code's Edit tool - performs exact string replacement. + * + * @param options - Options + * @param options.url - Script URL (inline scripts have `inline://{id}` URLs) + * @param options.oldString - Exact string to find and replace + * @param options.newString - Replacement string + * @param options.dryRun - If true, validate without applying (default false) + * @returns Result with success status + * + * @example + * ```ts + * // Replace a string + * await editor.edit({ + * url: 'https://example.com/app.js', + * oldString: 'const DEBUG = false', + * newString: 'const DEBUG = true' + * }) + * + * // Edit inline script + * await editor.edit({ + * url: 'inline://42', + * oldString: 'old code', + * newString: 'new code' + * }) + * ``` + */ + async edit({ + url, + oldString, + newString, + dryRun = false, + }: { + url: string + oldString: string + newString: string + dryRun?: boolean + }): Promise { + await this.enable() + const script = this.getScriptByUrl(url) + + let source = this.sourceCache.get(script.scriptId) + if (!source) { + const response = await this.cdp.send('Debugger.getScriptSource', { scriptId: script.scriptId }) + source = response.scriptSource + this.sourceCache.set(script.scriptId, source) + } + + const matchCount = source.split(oldString).length - 1 + if (matchCount === 0) { + throw new Error(`oldString not found in ${url}`) + } + if (matchCount > 1) { + throw new Error(`oldString found ${matchCount} times in ${url}. Provide more context to make it unique.`) + } + + const newSource = source.replace(oldString, newString) + + const response = await this.cdp.send('Debugger.setScriptSource', { + scriptId: script.scriptId, + scriptSource: newSource, + dryRun, + }) + + if (!dryRun) { + this.sourceCache.set(script.scriptId, newSource) + } + + return { + success: true, + stackChanged: response.stackChanged, + } + } + + /** + * Searches for a regex across all scripts. + * Like Claude Code's Grep tool - returns matching lines with context. + * + * @param options - Options + * @param options.regex - Regular expression to search for + * @param options.include - Optional URL substring to filter which scripts to search + * @returns Array of matches with url, line number, and line content + * + * @example + * ```ts + * // Search all scripts for "fetchUser" + * const matches = await editor.grep({ regex: /fetchUser/ }) + * + * // Search only in app scripts + * const matches = await editor.grep({ + * regex: /TODO/i, + * include: 'app' + * }) + * + * // Regex search for console methods + * const matches = await editor.grep({ + * regex: /console\.(log|error|warn)/ + * }) + * ``` + */ + async grep({ regex, include }: { regex: RegExp; include?: string }): Promise { + await this.enable() + + const matches: SearchMatch[] = [] + const scripts = include ? this.list({ search: include }) : this.list() + + for (const script of scripts) { + let source = this.sourceCache.get(script.scriptId) + if (!source) { + try { + const response = await this.cdp.send('Debugger.getScriptSource', { scriptId: script.scriptId }) + source = response.scriptSource + this.sourceCache.set(script.scriptId, source) + } catch { + continue + } + } + + const lines = source.split('\n') + for (let i = 0; i < lines.length; i++) { + if (regex.test(lines[i])) { + matches.push({ + url: script.url, + lineNumber: i + 1, + lineContent: lines[i].trim().slice(0, 200), + }) + regex.lastIndex = 0 + } + } + } + + return matches + } + + /** + * Writes entire content to a script, replacing all existing code. + * Use with caution - prefer edit() for targeted changes. + * + * @param options - Options + * @param options.url - Script URL (inline scripts have `inline://{id}` URLs) + * @param options.content - New script content + * @param options.dryRun - If true, validate without applying (default false) + */ + async write({ url, content, dryRun = false }: { url: string; content: string; dryRun?: boolean }): Promise { + await this.enable() + const script = this.getScriptByUrl(url) + + const response = await this.cdp.send('Debugger.setScriptSource', { + scriptId: script.scriptId, + scriptSource: content, + dryRun, + }) + + if (!dryRun) { + this.sourceCache.set(script.scriptId, content) + } + + return { + success: true, + stackChanged: response.stackChanged, + } + } +} diff --git a/playwriter/src/mcp-client.ts b/playwriter/src/mcp-client.ts index 8adaace..274874b 100644 --- a/playwriter/src/mcp-client.ts +++ b/playwriter/src/mcp-client.ts @@ -9,23 +9,28 @@ const __filename = url.fileURLToPath(import.meta.url) export interface CreateTransportOptions { clientName?: string + port?: number } -export async function createTransport(args: string[]): Promise<{ +export async function createTransport({ args = [], port }: { args?: string[]; port?: number } = {}): Promise<{ transport: Transport stderr: Stream | null }> { + const env: Record = { + ...process.env as Record, + DEBUG: 'playwriter:mcp:test', + DEBUG_COLORS: '0', + DEBUG_HIDE_DATE: '1', + } + if (port) { + env.PLAYWRITER_PORT = String(port) + } const transport = new StdioClientTransport({ command: 'pnpm', args: ['vite-node', path.join(path.dirname(__filename), 'mcp.ts'), ...args], cwd: path.join(path.dirname(__filename), '..'), stderr: 'pipe', - env: { - ...process.env, - DEBUG: 'playwriter:mcp:test', - DEBUG_COLORS: '0', - DEBUG_HIDE_DATE: '1', - }, + env, }) return { @@ -44,7 +49,7 @@ export async function createMCPClient(options?: CreateTransportOptions): Promise version: '1.0.0', }) - const { transport, stderr } = await createTransport([]) + const { transport, stderr } = await createTransport({ port: options?.port }) let stderrBuffer = '' stderr?.on('data', (data) => { diff --git a/playwriter/src/mcp.test.ts b/playwriter/src/mcp.test.ts index fdf59e4..1f037e8 100644 --- a/playwriter/src/mcp.test.ts +++ b/playwriter/src/mcp.test.ts @@ -12,6 +12,7 @@ import type { Protocol } from 'devtools-protocol' import { imageSize } from 'image-size' import { getCDPSessionForPage } from './cdp-session.js' import { Debugger } from './debugger.js' +import { Editor } from './editor.js' import { startPlayWriterCDPRelayServer, type RelayServer } from './extension/cdp-relay.js' import { createFileLogger } from './create-logger.js' import type { CDPCommand } from './cdp-types.js' @@ -20,6 +21,7 @@ import { killPortProcess } from 'kill-port-process' declare const window: any declare const document: any +const TEST_PORT = 19987 const execAsync = promisify(exec) @@ -67,15 +69,15 @@ interface TestContext { } async function setupTestContext({ tempDirPrefix }: { tempDirPrefix: string }): Promise { - await killProcessOnPort(19988) + await killProcessOnPort(TEST_PORT) console.log('Building extension...') - await execAsync('TESTING=1 pnpm build', { cwd: '../extension' }) + await execAsync(`TESTING=1 PLAYWRITER_PORT=${TEST_PORT} pnpm build`, { cwd: '../extension' }) console.log('Extension built') const localLogPath = path.join(process.cwd(), 'relay-server.log') const logger = createFileLogger({ logFilePath: localLogPath }) - const relayServer = await startPlayWriterCDPRelayServer({ port: 19988, logger }) + const relayServer = await startPlayWriterCDPRelayServer({ port: TEST_PORT, logger }) const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), tempDirPrefix)) const extensionPath = path.resolve('../extension/dist') @@ -136,7 +138,7 @@ describe('MCP Server Tests', () => { beforeAll(async () => { testCtx = await setupTestContext({ tempDirPrefix: 'pw-test-' }) - const result = await createMCPClient() + const result = await createMCPClient({ port: TEST_PORT }) client = result.client cleanup = result.cleanup }, 600000) @@ -165,7 +167,7 @@ describe('MCP Server Tests', () => { }) await new Promise(r => setTimeout(r, 500)) - const browser = await chromium.connectOverCDP(getCdpUrl()) + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) const cdpPage = browser.contexts()[0].pages().find(p => { return p.url().startsWith('about:') }) @@ -382,7 +384,7 @@ describe('MCP Server Tests', () => { // 3. Verify we can connect via direct CDP and see the page - let directBrowser = await chromium.connectOverCDP(getCdpUrl()) + let directBrowser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) let contexts = directBrowser.contexts() let pages = contexts[0].pages() @@ -408,7 +410,7 @@ describe('MCP Server Tests', () => { // connecting to relay will succeed, but listing pages should NOT show our page // Connect to relay again - directBrowser = await chromium.connectOverCDP(getCdpUrl()) + directBrowser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) contexts = directBrowser.contexts() pages = contexts[0].pages() @@ -426,7 +428,7 @@ describe('MCP Server Tests', () => { // 7. Verify page is back - directBrowser = await chromium.connectOverCDP(getCdpUrl()) + directBrowser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) // Wait a bit for targets to populate await new Promise(r => setTimeout(r, 500)) @@ -454,7 +456,7 @@ describe('MCP Server Tests', () => { const serviceWorker = await getExtensionServiceWorker(browserContext) // Connect once - const directBrowser = await chromium.connectOverCDP(getCdpUrl()) + const directBrowser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) // Wait a bit for connection and initial target discovery await new Promise(r => setTimeout(r, 500)) @@ -536,7 +538,7 @@ describe('MCP Server Tests', () => { }) // 3. Connect via CDP - const cdpUrl = getCdpUrl() + const cdpUrl = getCdpUrl({ port: TEST_PORT }) const directBrowser = await chromium.connectOverCDP(cdpUrl) const connectedPage = directBrowser.contexts()[0].pages().find(p => p.url() === initialUrl) expect(connectedPage).toBeDefined() @@ -617,7 +619,7 @@ describe('MCP Server Tests', () => { expect(targetIds.idA).not.toBe(targetIds.idB) // Verify independent connections - const browser = await chromium.connectOverCDP(getCdpUrl()) + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) const pages = browser.contexts()[0].pages() @@ -673,7 +675,7 @@ describe('MCP Server Tests', () => { }) // 3. Verify via CDP that the correct URL is shown - const browser = await chromium.connectOverCDP(getCdpUrl()) + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) // Wait for sync await new Promise(r => setTimeout(r, 1000)) @@ -1178,7 +1180,7 @@ describe('MCP Server Tests', () => { await new Promise(r => setTimeout(r, 2000)) - const browser = await chromium.connectOverCDP(getCdpUrl()) + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) const pages = browser.contexts()[0].pages() const xPage = pages.find(p => p.url().includes('x.com')) @@ -1204,7 +1206,7 @@ describe('MCP Server Tests', () => { }) for (let i = 0; i < 5; i++) { - const browser = await chromium.connectOverCDP(getCdpUrl()) + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) const pages = browser.contexts()[0].pages() const testPage = pages.find(p => p.url().includes('repeated-test')) @@ -1244,7 +1246,7 @@ describe('MCP Server Tests', () => { `, }, }), - chromium.connectOverCDP(getCdpUrl()) + chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) ]) const mcpOutput = (mcpResult as any).content[0].text @@ -1274,7 +1276,7 @@ describe('MCP Server Tests', () => { await new Promise(r => setTimeout(r, 3000)) for (let i = 0; i < 3; i++) { - const browser = await chromium.connectOverCDP(getCdpUrl()) + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) const pages = browser.contexts()[0].pages() const ytPage = pages.find(p => p.url().includes('youtube.com')) @@ -1310,7 +1312,7 @@ describe('MCP Server Tests', () => { } testCtx!.relayServer.on('cdp:command', commandHandler) - const browser = await chromium.connectOverCDP(getCdpUrl()) + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com')) expect(cdpPage).toBeDefined() @@ -1439,7 +1441,7 @@ describe('MCP Server Tests', () => { } testCtx!.relayServer.on('cdp:command', commandHandler) - const browser = await chromium.connectOverCDP(getCdpUrl()) + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) let cdpPage for (const p of browser.contexts()[0].pages()) { const html = await p.content() @@ -1546,11 +1548,11 @@ describe('MCP Server Tests', () => { await new Promise(r => setTimeout(r, 500)) - const browser = await chromium.connectOverCDP(getCdpUrl()) + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com')) expect(cdpPage).toBeDefined() - const wsUrl = getCdpUrl() + const wsUrl = getCdpUrl({ port: TEST_PORT }) const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl }) const layoutMetrics = await cdpSession.send('Page.getLayoutMetrics') @@ -1626,11 +1628,11 @@ describe('MCP Server Tests', () => { await new Promise(r => setTimeout(r, 500)) - const browser = await chromium.connectOverCDP(getCdpUrl()) + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com')) expect(cdpPage).toBeDefined() - const wsUrl = getCdpUrl() + const wsUrl = getCdpUrl({ port: TEST_PORT }) const client = await getCDPSessionForPage({ page: cdpPage!, wsUrl }) const layoutMetrics = await client.send('Page.getLayoutMetrics') @@ -1671,7 +1673,7 @@ describe('MCP Server Tests', () => { verbose: 1, disablePino: true, localBrowserLaunchOptions: { - cdpUrl: getCdpUrl(), + cdpUrl: getCdpUrl({ port: TEST_PORT }), }, }) @@ -1796,11 +1798,11 @@ describe('CDP Session Tests', () => { }) await new Promise(r => setTimeout(r, 500)) - const browser = await chromium.connectOverCDP(getCdpUrl()) + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com')) expect(cdpPage).toBeDefined() - const wsUrl = getCdpUrl() + const wsUrl = getCdpUrl({ port: TEST_PORT }) const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl }) const dbg = new Debugger({ cdp: cdpSession }) @@ -1866,11 +1868,11 @@ describe('CDP Session Tests', () => { }) await new Promise(r => setTimeout(r, 500)) - const browser = await chromium.connectOverCDP(getCdpUrl()) + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('news.ycombinator.com')) expect(cdpPage).toBeDefined() - const wsUrl = getCdpUrl() + const wsUrl = getCdpUrl({ port: TEST_PORT }) const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl }) const dbg = new Debugger({ cdp: cdpSession }) @@ -1916,7 +1918,7 @@ describe('CDP Session Tests', () => { }) await new Promise(r => setTimeout(r, 500)) - const browser = await chromium.connectOverCDP(getCdpUrl()) + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) let cdpPage for (const p of browser.contexts()[0].pages()) { const html = await p.content() @@ -1927,7 +1929,7 @@ describe('CDP Session Tests', () => { } expect(cdpPage).toBeDefined() - const wsUrl = getCdpUrl() + const wsUrl = getCdpUrl({ port: TEST_PORT }) const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl }) const dbg = new Debugger({ cdp: cdpSession }) @@ -1965,11 +1967,11 @@ describe('CDP Session Tests', () => { }) await new Promise(r => setTimeout(r, 500)) - const browser = await chromium.connectOverCDP(getCdpUrl()) + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com')) expect(cdpPage).toBeDefined() - const wsUrl = getCdpUrl() + const wsUrl = getCdpUrl({ port: TEST_PORT }) const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl }) const dbg = new Debugger({ cdp: cdpSession }) @@ -2038,11 +2040,11 @@ describe('CDP Session Tests', () => { }) await new Promise(r => setTimeout(r, 500)) - const browser = await chromium.connectOverCDP(getCdpUrl()) + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com')) expect(cdpPage).toBeDefined() - const wsUrl = getCdpUrl() + const wsUrl = getCdpUrl({ port: TEST_PORT }) const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl }) await cdpSession.send('Profiler.enable') await cdpSession.send('Profiler.start') @@ -2093,11 +2095,11 @@ describe('CDP Session Tests', () => { }) await new Promise(r => setTimeout(r, 500)) - const browser = await chromium.connectOverCDP(getCdpUrl()) + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com')) expect(cdpPage).toBeDefined() - const wsUrl = getCdpUrl() + const wsUrl = getCdpUrl({ port: TEST_PORT }) const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl }) const initialTargets = await cdpSession.send('Target.getTargets') @@ -2144,11 +2146,11 @@ describe('CDP Session Tests', () => { await new Promise(r => setTimeout(r, 500)) - const browser = await chromium.connectOverCDP(getCdpUrl()) + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com')) expect(cdpPage).toBeDefined() - const wsUrl = getCdpUrl() + const wsUrl = getCdpUrl({ port: TEST_PORT }) const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl }) const { targetInfos } = await cdpSession.send('Target.getTargets') @@ -2196,11 +2198,11 @@ describe('CDP Session Tests', () => { await page.goto('https://news.ycombinator.com/', { waitUntil: 'networkidle' }) await new Promise(r => setTimeout(r, 500)) - const browser = await chromium.connectOverCDP(getCdpUrl()) + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('news.ycombinator.com')) expect(cdpPage).toBeDefined() - const wsUrl = getCdpUrl() + const wsUrl = getCdpUrl({ port: TEST_PORT }) const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl }) const evalResult = await cdpSession.send('Runtime.evaluate', { @@ -2228,11 +2230,11 @@ describe('CDP Session Tests', () => { }) await new Promise(r => setTimeout(r, 500)) - const browser = await chromium.connectOverCDP(getCdpUrl()) + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com')) expect(cdpPage).toBeDefined() - const wsUrl = getCdpUrl() + const wsUrl = getCdpUrl({ port: TEST_PORT }) const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl }) const initialEvalResult = await cdpSession.send('Runtime.evaluate', { @@ -2280,11 +2282,11 @@ describe('CDP Session Tests', () => { }) await new Promise(r => setTimeout(r, 500)) - const browser = await chromium.connectOverCDP(getCdpUrl()) + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com')) expect(cdpPage).toBeDefined() - const wsUrl = getCdpUrl() + const wsUrl = getCdpUrl({ port: TEST_PORT }) const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl }) const dbg = new Debugger({ cdp: cdpSession }) @@ -2356,7 +2358,7 @@ describe('CDP Session Tests', () => { }) await new Promise(r => setTimeout(r, 500)) - const browser = await chromium.connectOverCDP(getCdpUrl()) + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) let cdpPage for (const p of browser.contexts()[0].pages()) { const html = await p.content() @@ -2367,7 +2369,7 @@ describe('CDP Session Tests', () => { } expect(cdpPage).toBeDefined() - const wsUrl = getCdpUrl() + const wsUrl = getCdpUrl({ port: TEST_PORT }) const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl }) const dbg = new Debugger({ cdp: cdpSession }) @@ -2420,7 +2422,7 @@ describe('CDP Session Tests', () => { }) await new Promise(r => setTimeout(r, 500)) - const browser = await chromium.connectOverCDP(getCdpUrl()) + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com')) expect(cdpPage).toBeDefined() @@ -2447,4 +2449,72 @@ describe('CDP Session Tests', () => { await browser.close() await page.close() }, 60000) + + it('should use Editor class to list, read, and edit scripts', async () => { + const browserContext = getBrowserContext() + const serviceWorker = await getExtensionServiceWorker(browserContext) + + const page = await browserContext.newPage() + await page.goto('https://example.com/') + await page.bringToFront() + + await serviceWorker.evaluate(async () => { + await globalThis.toggleExtensionForActiveTab() + }) + await new Promise(r => setTimeout(r, 500)) + + const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })) + const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com')) + expect(cdpPage).toBeDefined() + + const wsUrl = getCdpUrl({ port: TEST_PORT }) + const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl }) + const editor = new Editor({ cdp: cdpSession }) + + await editor.enable() + + await cdpPage!.addScriptTag({ + content: ` + function greetUser(name) { + console.log('Hello, ' + name); + return 'Hello, ' + name; + } + `, + }) + await new Promise(r => setTimeout(r, 300)) + + const scripts = editor.list() + expect(scripts.length).toBeGreaterThan(0) + + const matches = await editor.grep({ regex: /greetUser/ }) + expect(matches.length).toBeGreaterThan(0) + + const match = matches[0] + const { content, totalLines } = await editor.read({ url: match.url }) + expect(content).toContain('greetUser') + expect(totalLines).toBeGreaterThan(0) + + await editor.edit({ + url: match.url, + oldString: "console.log('Hello, ' + name);", + newString: "console.log('Hello, ' + name); console.log('EDITOR_TEST_MARKER');", + }) + + const consoleLogs: string[] = [] + cdpPage!.on('console', msg => { + consoleLogs.push(msg.text()) + }) + + await cdpPage!.evaluate(() => { + (window as any).greetUser('World') + }) + await new Promise(r => setTimeout(r, 200)) + + expect(consoleLogs).toContain('Hello, World') + expect(consoleLogs).toContain('EDITOR_TEST_MARKER') + + cdpSession.close() + await browser.close() + await page.close() + }, 60000) }) diff --git a/playwriter/src/mcp.ts b/playwriter/src/mcp.ts index 5f48fb5..d0b436e 100644 --- a/playwriter/src/mcp.ts +++ b/playwriter/src/mcp.ts @@ -16,6 +16,7 @@ import { killPortProcess } from 'kill-port-process' import { waitForPageLoad, WaitForPageLoadOptions, WaitForPageLoadResult } from './wait-for-page-load.js' import { getCDPSessionForPage, CDPSession } from './cdp-session.js' import { Debugger } from './debugger.js' +import { Editor } from './editor.js' class CodeExecutionTimeoutError extends Error { constructor(timeout: number) { @@ -74,6 +75,7 @@ interface VMContext { waitForPageLoad: (options: WaitForPageLoadOptions) => Promise getCDPSession: (options: { page: Page }) => Promise createDebugger: (options: { cdp: CDPSession }) => Debugger + createEditor: (options: { cdp: CDPSession }) => Editor require: NodeRequire import: (specifier: string) => Promise } @@ -101,7 +103,7 @@ const lastSnapshots: WeakMap = new WeakMap() // Cache CDP sessions per page const cdpSessionCache: WeakMap = new WeakMap() -const RELAY_PORT = 19988 +const RELAY_PORT = Number(process.env.PLAYWRITER_PORT) || 19988 const NO_TABS_ERROR = `No browser tabs are connected. Please install and enable the Playwriter extension on at least one tab: https://chromewebstore.google.com/detail/playwriter-mcp/jfeammnjpkecdekppnclgkkffahnhfhe` async function setDeviceScaleFactorForMacOS(context: BrowserContext): Promise { @@ -426,6 +428,43 @@ server.resource('debugger-api', 'playwriter://debugger-api', { mimeType: 'text/p } }) +server.resource('editor-api', 'playwriter://editor-api', { mimeType: 'text/plain' }, async () => { + const packageJsonPath = require.resolve('playwriter/package.json') + const distDir = path.join(path.dirname(packageJsonPath), 'dist') + + const editorTypes = fs + .readFileSync(path.join(distDir, 'editor.d.ts'), 'utf-8') + .replace(/\/\/# sourceMappingURL=.*$/gm, '') + .trim() + const editorExamples = fs.readFileSync(path.join(distDir, 'editor-examples.ts'), 'utf-8') + + return { + contents: [ + { + uri: 'playwriter://editor-api', + text: dedent` + # Editor API Reference + + The Editor class provides a Claude Code-like interface for viewing and editing web page scripts at runtime. + + ## Types + + \`\`\`ts + ${editorTypes} + \`\`\` + + ## Examples + + \`\`\`ts + ${editorExamples} + \`\`\` + `, + mimeType: 'text/plain', + }, + ], + } +}) + server.tool( 'execute', promptContent, @@ -625,6 +664,10 @@ server.tool( return new Debugger(options) } + const createEditor = (options: { cdp: CDPSession }) => { + return new Editor(options) + } + let vmContextObj: VMContextWithGlobals = { page, context, @@ -637,6 +680,7 @@ server.tool( waitForPageLoad, getCDPSession, createDebugger, + createEditor, resetPlaywright: async () => { const { page: newPage, context: newContext } = await resetConnection() @@ -652,6 +696,7 @@ server.tool( waitForPageLoad, getCDPSession, createDebugger, + createEditor, resetPlaywright: vmContextObj.resetPlaywright, require, // TODO --experimental-vm-modules is needed to make import work in vm diff --git a/playwriter/src/prompt.md b/playwriter/src/prompt.md index 7c237ea..2d77d1a 100644 --- a/playwriter/src/prompt.md +++ b/playwriter/src/prompt.md @@ -97,14 +97,38 @@ you have access to some functions in addition to playwright methods: - Example: `const cdp = await getCDPSession({ page }); const metrics = await cdp.send('Page.getLayoutMetrics');` - `createDebugger({ cdp })`: creates a Debugger instance for setting breakpoints, stepping, and inspecting variables. Works with browser JS or Node.js (--inspect). - `cdp`: a CDPSession from `getCDPSession` - - Methods: `enable()`, `setBreakpoint({ file, line })`, `deleteBreakpoint({ breakpointId })`, `listBreakpoints()`, `listScripts({ search? })`, `evaluate({ expression })`, `inspectVariables({ scope? })`, `getLocation()`, `stepOver()`, `stepInto()`, `stepOut()`, `resume()`, `isPaused()` + - Methods: `enable()`, `setBreakpoint({ file, line, condition? })`, `deleteBreakpoint({ breakpointId })`, `listBreakpoints()`, `listScripts({ search? })`, `evaluate({ expression })`, `inspectLocalVariables()`, `getLocation()`, `stepOver()`, `stepInto()`, `stepOut()`, `resume()`, `isPaused()`, `setXHRBreakpoint({ url })`, `removeXHRBreakpoint({ url })`, `listXHRBreakpoints()`, `setBlackboxPatterns({ patterns })`, `addBlackboxPattern({ pattern })`, `removeBlackboxPattern({ pattern })`, `listBlackboxPatterns()` - Example: ```js const cdp = await getCDPSession({ page }); const dbg = createDebugger({ cdp }); await dbg.enable(); console.log(dbg.listScripts({ search: 'app' })); await dbg.setBreakpoint({ file: 'https://example.com/app.js', line: 42 }); + // conditional breakpoint - only pause when userId is 123 + await dbg.setBreakpoint({ file: 'app.js', line: 50, condition: 'userId === 123' }); + // XHR breakpoint - pause when fetch/XHR URL contains '/api' + await dbg.setXHRBreakpoint({ url: '/api' }); + // blackbox framework code when stepping + await dbg.setBlackboxPatterns({ patterns: ['node_modules/'] }); // user triggers the code, then: - if (dbg.isPaused()) { console.log(await dbg.getLocation()); console.log(await dbg.inspectVariables()); await dbg.resume(); } + if (dbg.isPaused()) { console.log(await dbg.getLocation()); console.log(await dbg.inspectLocalVariables()); await dbg.resume(); } + ``` +- `createEditor({ cdp })`: creates an Editor instance for viewing and live-editing page scripts. Provides a Claude Code-like interface. + - `cdp`: a CDPSession from `getCDPSession` + - Methods: `enable()`, `list({ search? })`, `read({ url, offset?, limit? })`, `edit({ url, oldString, newString, dryRun? })`, `grep({ regex, include? })`, `write({ url, content, dryRun? })` + - Inline scripts (no URL) get synthetic URLs like `inline://42` - use grep() to find them by content + - Example: + ```js + const cdp = await getCDPSession({ page }); const editor = createEditor({ cdp }); await editor.enable(); + // list available scripts (inline scripts have inline:// URLs) + console.log(editor.list({ search: 'app' })); + // read a script with line numbers (like Claude Code Read tool) + const { content, totalLines } = await editor.read({ url: 'https://example.com/app.js', offset: 0, limit: 50 }); + console.log(content); + // edit a script (like Claude Code Edit tool) - exact string replacement + await editor.edit({ url: 'https://example.com/app.js', oldString: 'DEBUG = false', newString: 'DEBUG = true' }); + // search across all scripts (like Grep) - useful for finding inline scripts + const matches = await editor.grep({ regex: /myFunction/ }); + if (matches.length > 0) { await editor.edit({ url: matches[0].url, oldString: 'return false', newString: 'return true' }); } ``` example: