add editor. use different port in tests

This commit is contained in:
Tommy D. Rossi
2025-12-26 00:01:05 +01:00
parent c693c7d775
commit c2d13c0363
9 changed files with 658 additions and 62 deletions
+7 -4
View File
@@ -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<void> {
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<void> {
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 {
+3 -1
View File
@@ -7,7 +7,9 @@ import fs from 'node:fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const defineEnv: Record<string, string> = {};
const defineEnv: Record<string, string> = {
'process.env.PLAYWRITER_PORT': JSON.stringify(process.env.PLAYWRITER_PORT || '19988'),
};
if (process.env.TESTING) {
defineEnv['import.meta.env.TESTING'] = 'true';
}
@@ -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<CDPSession>
export declare const createDebugger: (options: { cdp: CDPSession }) => Debugger
export declare const createEditor: (options: { cdp: CDPSession }) => Editor
export declare const console: { log: (...args: unknown[]) => void }
+104
View File
@@ -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 }
+341
View File
@@ -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<string, ScriptInfo>()
private scriptsByUrl = new Map<string, ScriptInfo>()
private sourceCache = new Map<string, string>()
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<void> {
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<ReadResult> {
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<EditResult> {
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<SearchMatch[]> {
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<EditResult> {
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,
}
}
}
+13 -8
View File
@@ -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<string, string> = {
...process.env as Record<string, string>,
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) => {
+116 -46
View File
@@ -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<TestContext> {
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)
})
+46 -1
View File
@@ -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<WaitForPageLoadResult>
getCDPSession: (options: { page: Page }) => Promise<CDPSession>
createDebugger: (options: { cdp: CDPSession }) => Debugger
createEditor: (options: { cdp: CDPSession }) => Editor
require: NodeRequire
import: (specifier: string) => Promise<any>
}
@@ -101,7 +103,7 @@ const lastSnapshots: WeakMap<Page, string> = new WeakMap()
// Cache CDP sessions per page
const cdpSessionCache: WeakMap<Page, CDPSession> = 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<void> {
@@ -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
+26 -2
View File
@@ -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: