add support for remote agent in different host than extension. add

support for editing css.
This commit is contained in:
Tommy D. Rossi
2025-12-27 18:48:52 +01:00
parent e63dc52c68
commit e4695e231d
15 changed files with 495 additions and 195 deletions
+18
View File
@@ -177,6 +177,24 @@ The irony is that by trying to make browser control "simpler" with dedicated too
+---------------------+ +---------------------+
``` ```
## Remote Agents (Devcontainers, VMs, SSH)
Run agents in isolated environments (devcontainers, VMs, SSH) while controlling Chrome on your host.
**On host (where Chrome runs):**
```bash
npx playwriter serve --token <secret>
```
**In container/VM (where agent runs):**
```bash
export PLAYWRITER_URL="ws://host.docker.internal:19988?token=<secret>"
```
Use `host.docker.internal` for devcontainers, or your host's IP for VMs/SSH.
## Security ## Security
Playwriter is designed with security in mind, ensuring that only you can control your browser. Playwriter is designed with security in mind, ensuring that only you can control your browser.
+1 -1
View File
@@ -1,3 +1,3 @@
#!/usr/bin/env node #!/usr/bin/env node
import './dist/mcp.js' import './dist/cli.js'
+1
View File
@@ -41,6 +41,7 @@
"@hono/node-server": "^1.19.6", "@hono/node-server": "^1.19.6",
"@hono/node-ws": "^1.2.0", "@hono/node-ws": "^1.2.0",
"@modelcontextprotocol/sdk": "^1.21.1", "@modelcontextprotocol/sdk": "^1.21.1",
"cac": "^6.7.14",
"chalk": "^5.6.2", "chalk": "^5.6.2",
"devtools-protocol": "^0.0.1543509", "devtools-protocol": "^0.0.1543509",
"diff": "^8.0.2", "diff": "^8.0.2",
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env node
import { cac } from 'cac'
import { startPlayWriterCDPRelayServer } from './extension/cdp-relay.js'
import { createFileLogger } from './create-logger.js'
import { VERSION } from './utils.js'
const RELAY_PORT = 19988
const cli = cac('playwriter')
cli
.command('', 'Start the MCP server (default)')
.action(async () => {
const { startMcp } = await import('./mcp.js')
await startMcp()
})
cli
.command('serve', 'Start the CDP relay server for remote MCP connections')
.option('--host <host>', 'Host to bind to', { default: '0.0.0.0' })
.option('--token <token>', 'Authentication token for /cdp/* endpoints')
.action(async (options: { host: string; token?: string }) => {
const logger = createFileLogger()
process.title = 'playwriter-serve'
process.on('uncaughtException', async (err) => {
await logger.error('Uncaught Exception:', err)
process.exit(1)
})
process.on('unhandledRejection', async (reason) => {
await logger.error('Unhandled Rejection:', reason)
process.exit(1)
})
const server = await startPlayWriterCDPRelayServer({
port: RELAY_PORT,
host: options.host,
token: options.token,
logger,
})
console.log('Playwriter CDP relay server started')
console.log(` Host: ${options.host}`)
console.log(` Port: ${RELAY_PORT}`)
console.log(` Token: ${options.token ? '(configured)' : '(none)'}`)
console.log(` Logs: ${logger.logFilePath}`)
console.log('')
console.log('Endpoints:')
console.log(` Extension: ws://${options.host}:${RELAY_PORT}/extension`)
console.log(` CDP: ws://${options.host}:${RELAY_PORT}/cdp/<client-id>${options.token ? '?token=<token>' : ''}`)
console.log('')
console.log('Press Ctrl+C to stop.')
process.on('SIGINT', () => {
console.log('\nShutting down...')
server.close()
process.exit(0)
})
process.on('SIGTERM', () => {
console.log('\nShutting down...')
server.close()
process.exit(0)
})
})
cli.help()
cli.version(VERSION)
cli.parse()
+47 -3
View File
@@ -6,7 +6,7 @@ async function listScripts() {
const editor = createEditor({ cdp }) const editor = createEditor({ cdp })
await editor.enable() await editor.enable()
const scripts = editor.list({ search: 'app' }) const scripts = editor.list({ pattern: /app/ })
console.log(scripts) console.log(scripts)
} }
@@ -62,7 +62,7 @@ async function searchScripts() {
const todoMatches = await editor.grep({ const todoMatches = await editor.grep({
regex: /TODO|FIXME/i, regex: /TODO|FIXME/i,
include: 'app', pattern: /app/,
}) })
console.log(todoMatches) console.log(todoMatches)
} }
@@ -101,4 +101,48 @@ async function editInlineScript() {
} }
} }
export { listScripts, readScript, editScript, searchScripts, writeScript, editInlineScript } // Example: List and read CSS stylesheets
async function readStylesheet() {
const cdp = await getCDPSession({ page })
const editor = createEditor({ cdp })
await editor.enable()
const stylesheets = editor.list({ pattern: /\.css/ })
console.log('Stylesheets:', stylesheets)
if (stylesheets.length > 0) {
const { content, totalLines } = await editor.read({
url: stylesheets[0],
})
console.log('Total lines:', totalLines)
console.log(content)
}
}
// Example: Edit a CSS stylesheet
async function editStylesheet() {
const cdp = await getCDPSession({ page })
const editor = createEditor({ cdp })
await editor.enable()
await editor.edit({
url: 'https://example.com/styles.css',
oldString: 'color: red',
newString: 'color: blue',
})
}
// Example: Search CSS for specific properties
async function searchStyles() {
const cdp = await getCDPSession({ page })
const editor = createEditor({ cdp })
await editor.enable()
const matches = await editor.grep({
regex: /background-color/,
pattern: /\.css/,
})
console.log(matches)
}
export { listScripts, readScript, editScript, searchScripts, writeScript, editInlineScript, readStylesheet, editStylesheet, searchStyles }
+128 -109
View File
@@ -1,10 +1,5 @@
import type { CDPSession } from './cdp-session.js' import type { CDPSession } from './cdp-session.js'
export interface ScriptInfo {
url: string
scriptId: string
}
export interface ReadResult { export interface ReadResult {
content: string content: string
totalLines: number totalLines: number
@@ -53,8 +48,8 @@ export interface EditResult {
export class Editor { export class Editor {
private cdp: CDPSession private cdp: CDPSession
private enabled = false private enabled = false
private scripts = new Map<string, ScriptInfo>() private scripts = new Map<string, string>()
private scriptsByUrl = new Map<string, ScriptInfo>() private stylesheets = new Map<string, string>()
private sourceCache = new Map<string, string>() private sourceCache = new Map<string, string>()
constructor({ cdp }: { cdp: CDPSession }) { constructor({ cdp }: { cdp: CDPSession }) {
@@ -66,15 +61,20 @@ export class Editor {
this.cdp.on('Debugger.scriptParsed', (params) => { this.cdp.on('Debugger.scriptParsed', (params) => {
if (!params.url.startsWith('chrome') && !params.url.startsWith('devtools')) { if (!params.url.startsWith('chrome') && !params.url.startsWith('devtools')) {
const url = params.url || `inline://${params.scriptId}` const url = params.url || `inline://${params.scriptId}`
const info: ScriptInfo = { this.scripts.set(url, params.scriptId)
url,
scriptId: params.scriptId,
}
this.scripts.set(params.scriptId, info)
this.scriptsByUrl.set(url, info)
this.sourceCache.delete(params.scriptId) this.sourceCache.delete(params.scriptId)
} }
}) })
this.cdp.on('CSS.styleSheetAdded', (params) => {
const header = params.header
if (header.sourceURL?.startsWith('chrome') || header.sourceURL?.startsWith('devtools')) {
return
}
const url = header.sourceURL || `inline-css://${header.styleSheetId}`
this.stylesheets.set(url, header.styleSheetId)
this.sourceCache.delete(header.styleSheetId)
})
} }
/** /**
@@ -87,48 +87,67 @@ export class Editor {
return return
} }
await this.cdp.send('Debugger.enable') await this.cdp.send('Debugger.enable')
await this.cdp.send('DOM.enable')
await this.cdp.send('CSS.enable')
this.enabled = true this.enabled = true
} }
private getScriptByUrl(url: string): ScriptInfo { private getIdByUrl(url: string): { scriptId: string } | { styleSheetId: string } {
const script = this.scriptsByUrl.get(url) const scriptId = this.scripts.get(url)
if (!script) { if (scriptId) {
const available = Array.from(this.scriptsByUrl.keys()).slice(0, 5) return { scriptId }
throw new Error(`Script not found: ${url}\nAvailable: ${available.join(', ')}${this.scriptsByUrl.size > 5 ? '...' : ''}`)
} }
return script const styleSheetId = this.stylesheets.get(url)
if (styleSheetId) {
return { styleSheetId }
}
const allUrls = [...Array.from(this.scripts.keys()), ...Array.from(this.stylesheets.keys())]
const available = allUrls.slice(0, 5)
throw new Error(`Resource not found: ${url}\nAvailable: ${available.join(', ')}${allUrls.length > 5 ? '...' : ''}`)
} }
/** /**
* Lists available scripts. Use search to filter by URL substring. * Lists available script and stylesheet URLs. Use pattern to filter by regex.
* *
* @param options - Options * @param options - Options
* @param options.search - Optional substring to filter URLs (case-insensitive) * @param options.pattern - Optional regex to filter URLs
* @returns Array of scripts with url and scriptId * @returns Array of URLs
* *
* @example * @example
* ```ts * ```ts
* // List all scripts * // List all scripts and stylesheets
* const scripts = editor.list() * const urls = editor.list()
*
* // List only JS files
* const jsFiles = editor.list({ pattern: /\.js/ })
*
* // List only CSS files
* const cssFiles = editor.list({ pattern: /\.css/ })
* *
* // Search for specific scripts * // Search for specific scripts
* const appScripts = editor.list({ search: 'app' }) * const appScripts = editor.list({ pattern: /app/ })
* const reactScripts = editor.list({ search: 'react' })
* ``` * ```
*/ */
list({ search }: { search?: string } = {}): ScriptInfo[] { list({ pattern }: { pattern?: RegExp } = {}): string[] {
const scripts = Array.from(this.scripts.values()) const urls = [...Array.from(this.scripts.keys()), ...Array.from(this.stylesheets.keys())]
const filtered = search ? scripts.filter((s) => s.url.toLowerCase().includes(search.toLowerCase())) : scripts
return filtered if (!pattern) {
return urls
}
return urls.filter((url) => {
const matches = pattern.test(url)
pattern.lastIndex = 0
return matches
})
} }
/** /**
* Reads a script's source code by URL. * Reads a script or stylesheet's source code by URL.
* Returns line-numbered content like Claude Code's Read tool. * Returns line-numbered content like Claude Code's Read tool.
* For inline scripts, use the `inline://` URL from list() or grep(). * For inline scripts, use the `inline://` URL from list() or grep().
* *
* @param options - Options * @param options - Options
* @param options.url - Script URL (inline scripts have `inline://{id}` URLs) * @param options.url - Script or stylesheet URL (inline scripts have `inline://{id}` URLs)
* @param options.offset - Line number to start from (0-based, default 0) * @param options.offset - Line number to start from (0-based, default 0)
* @param options.limit - Number of lines to return (default 2000) * @param options.limit - Number of lines to return (default 2000)
* @returns Content with line numbers, total lines, and range info * @returns Content with line numbers, total lines, and range info
@@ -140,8 +159,8 @@ export class Editor {
* url: 'https://example.com/app.js' * url: 'https://example.com/app.js'
* }) * })
* *
* // Read inline script (URL from grep result) * // Read a CSS file
* const { content } = await editor.read({ url: 'inline://42' }) * const { content } = await editor.read({ url: 'https://example.com/styles.css' })
* *
* // Read lines 100-200 * // Read lines 100-200
* const { content } = await editor.read({ * const { content } = await editor.read({
@@ -153,14 +172,8 @@ export class Editor {
*/ */
async read({ url, offset = 0, limit = 2000 }: { url: string; offset?: number; limit?: number }): Promise<ReadResult> { async read({ url, offset = 0, limit = 2000 }: { url: string; offset?: number; limit?: number }): Promise<ReadResult> {
await this.enable() await this.enable()
const script = this.getScriptByUrl(url) const id = this.getIdByUrl(url)
const source = await this.getSource(id)
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 lines = source.split('\n')
const totalLines = lines.length const totalLines = lines.length
@@ -178,12 +191,31 @@ export class Editor {
} }
} }
private async getSource(id: { scriptId: string } | { styleSheetId: string }): Promise<string> {
if ('styleSheetId' in id) {
const cached = this.sourceCache.get(id.styleSheetId)
if (cached) {
return cached
}
const response = await this.cdp.send('CSS.getStyleSheetText', { styleSheetId: id.styleSheetId })
this.sourceCache.set(id.styleSheetId, response.text)
return response.text
}
const cached = this.sourceCache.get(id.scriptId)
if (cached) {
return cached
}
const response = await this.cdp.send('Debugger.getScriptSource', { scriptId: id.scriptId })
this.sourceCache.set(id.scriptId, response.scriptSource)
return response.scriptSource
}
/** /**
* Edits a script by replacing oldString with newString. * Edits a script or stylesheet by replacing oldString with newString.
* Like Claude Code's Edit tool - performs exact string replacement. * Like Claude Code's Edit tool - performs exact string replacement.
* *
* @param options - Options * @param options - Options
* @param options.url - Script URL (inline scripts have `inline://{id}` URLs) * @param options.url - Script or stylesheet URL (inline scripts have `inline://{id}` URLs)
* @param options.oldString - Exact string to find and replace * @param options.oldString - Exact string to find and replace
* @param options.newString - Replacement string * @param options.newString - Replacement string
* @param options.dryRun - If true, validate without applying (default false) * @param options.dryRun - If true, validate without applying (default false)
@@ -191,18 +223,18 @@ export class Editor {
* *
* @example * @example
* ```ts * ```ts
* // Replace a string * // Replace a string in JS
* await editor.edit({ * await editor.edit({
* url: 'https://example.com/app.js', * url: 'https://example.com/app.js',
* oldString: 'const DEBUG = false', * oldString: 'const DEBUG = false',
* newString: 'const DEBUG = true' * newString: 'const DEBUG = true'
* }) * })
* *
* // Edit inline script * // Edit CSS
* await editor.edit({ * await editor.edit({
* url: 'inline://42', * url: 'https://example.com/styles.css',
* oldString: 'old code', * oldString: 'color: red',
* newString: 'new code' * newString: 'color: blue'
* }) * })
* ``` * ```
*/ */
@@ -218,14 +250,8 @@ export class Editor {
dryRun?: boolean dryRun?: boolean
}): Promise<EditResult> { }): Promise<EditResult> {
await this.enable() await this.enable()
const script = this.getScriptByUrl(url) const id = this.getIdByUrl(url)
const source = await this.getSource(id)
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 const matchCount = source.split(oldString).length - 1
if (matchCount === 0) { if (matchCount === 0) {
@@ -236,72 +262,79 @@ export class Editor {
} }
const newSource = source.replace(oldString, newString) const newSource = source.replace(oldString, newString)
return this.setSource(id, newSource, dryRun)
}
private async setSource(
id: { scriptId: string } | { styleSheetId: string },
content: string,
dryRun = false
): Promise<EditResult> {
if ('styleSheetId' in id) {
await this.cdp.send('CSS.setStyleSheetText', { styleSheetId: id.styleSheetId, text: content })
if (!dryRun) {
this.sourceCache.set(id.styleSheetId, content)
}
return { success: true }
}
const response = await this.cdp.send('Debugger.setScriptSource', { const response = await this.cdp.send('Debugger.setScriptSource', {
scriptId: script.scriptId, scriptId: id.scriptId,
scriptSource: newSource, scriptSource: content,
dryRun, dryRun,
}) })
if (!dryRun) { if (!dryRun) {
this.sourceCache.set(script.scriptId, newSource) this.sourceCache.set(id.scriptId, content)
}
return {
success: true,
stackChanged: response.stackChanged,
} }
return { success: true, stackChanged: response.stackChanged }
} }
/** /**
* Searches for a regex across all scripts. * Searches for a regex across all scripts and stylesheets.
* Like Claude Code's Grep tool - returns matching lines with context. * Like Claude Code's Grep tool - returns matching lines with context.
* *
* @param options - Options * @param options - Options
* @param options.regex - Regular expression to search for * @param options.regex - Regular expression to search for in file contents
* @param options.include - Optional URL substring to filter which scripts to search * @param options.pattern - Optional regex to filter which URLs to search
* @returns Array of matches with url, line number, and line content * @returns Array of matches with url, line number, and line content
* *
* @example * @example
* ```ts * ```ts
* // Search all scripts for "fetchUser" * // Search all scripts and stylesheets for "color"
* const matches = await editor.grep({ regex: /fetchUser/ }) * const matches = await editor.grep({ regex: /color/ })
* *
* // Search only in app scripts * // Search only CSS files
* const matches = await editor.grep({ * const matches = await editor.grep({
* regex: /TODO/i, * regex: /background-color/,
* include: 'app' * pattern: /\.css/
* }) * })
* *
* // Regex search for console methods * // Regex search for console methods in JS
* const matches = await editor.grep({ * const matches = await editor.grep({
* regex: /console\.(log|error|warn)/ * regex: /console\.(log|error|warn)/,
* pattern: /\.js/
* }) * })
* ``` * ```
*/ */
async grep({ regex, include }: { regex: RegExp; include?: string }): Promise<SearchMatch[]> { async grep({ regex, pattern }: { regex: RegExp; pattern?: RegExp }): Promise<SearchMatch[]> {
await this.enable() await this.enable()
const matches: SearchMatch[] = [] const matches: SearchMatch[] = []
const scripts = include ? this.list({ search: include }) : this.list() const urls = this.list({ pattern })
for (const script of scripts) { for (const url of urls) {
let source = this.sourceCache.get(script.scriptId) let source: string
if (!source) { try {
try { const id = this.getIdByUrl(url)
const response = await this.cdp.send('Debugger.getScriptSource', { scriptId: script.scriptId }) source = await this.getSource(id)
source = response.scriptSource } catch {
this.sourceCache.set(script.scriptId, source) continue
} catch {
continue
}
} }
const lines = source.split('\n') const lines = source.split('\n')
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
if (regex.test(lines[i])) { if (regex.test(lines[i])) {
matches.push({ matches.push({
url: script.url, url,
lineNumber: i + 1, lineNumber: i + 1,
lineContent: lines[i].trim().slice(0, 200), lineContent: lines[i].trim().slice(0, 200),
}) })
@@ -314,31 +347,17 @@ export class Editor {
} }
/** /**
* Writes entire content to a script, replacing all existing code. * Writes entire content to a script or stylesheet, replacing all existing code.
* Use with caution - prefer edit() for targeted changes. * Use with caution - prefer edit() for targeted changes.
* *
* @param options - Options * @param options - Options
* @param options.url - Script URL (inline scripts have `inline://{id}` URLs) * @param options.url - Script or stylesheet URL (inline scripts have `inline://{id}` URLs)
* @param options.content - New script content * @param options.content - New content
* @param options.dryRun - If true, validate without applying (default false) * @param options.dryRun - If true, validate without applying (default false, only works for JS)
*/ */
async write({ url, content, dryRun = false }: { url: string; content: string; dryRun?: boolean }): Promise<EditResult> { async write({ url, content, dryRun = false }: { url: string; content: string; dryRun?: boolean }): Promise<EditResult> {
await this.enable() await this.enable()
const script = this.getScriptByUrl(url) const id = this.getIdByUrl(url)
return this.setSource(id, content, dryRun)
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,
}
} }
} }
+11 -2
View File
@@ -29,7 +29,7 @@ export type RelayServer = {
off<K extends keyof RelayServerEvents>(event: K, listener: RelayServerEvents[K]): void off<K extends keyof RelayServerEvents>(event: K, listener: RelayServerEvents[K]): void
} }
export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.0.0.1', logger }: { port?: number; host?: string; logger?: { log(...args: any[]): void; error(...args: any[]): void } } = {}): Promise<RelayServer> { export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.0.0.1', token, logger }: { port?: number; host?: string; token?: string; logger?: { log(...args: any[]): void; error(...args: any[]): void } } = {}): Promise<RelayServer> {
const emitter = new EventEmitter() const emitter = new EventEmitter()
const connectedTargets = new Map<string, ConnectedTarget>() const connectedTargets = new Map<string, ConnectedTarget>()
@@ -297,7 +297,16 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
} }
}) })
app.get('/cdp/:clientId?', upgradeWebSocket((c) => { app.get('/cdp/:clientId?', (c, next) => {
if (token) {
const url = new URL(c.req.url, 'http://localhost')
const providedToken = url.searchParams.get('token')
if (providedToken !== token) {
return c.text('Unauthorized', 401)
}
}
return next()
}, upgradeWebSocket((c) => {
const clientId = c.req.param('clientId') || 'default' const clientId = c.req.param('clientId') || 'default'
return { return {
+1 -1
View File
@@ -27,7 +27,7 @@ export async function createTransport({ args = [], port }: { args?: string[]; po
} }
const transport = new StdioClientTransport({ const transport = new StdioClientTransport({
command: 'pnpm', command: 'pnpm',
args: ['vite-node', path.join(path.dirname(__filename), 'mcp.ts'), ...args], args: ['vite-node', path.join(path.dirname(__filename), 'cli.ts'), ...args],
cwd: path.join(path.dirname(__filename), '..'), cwd: path.join(path.dirname(__filename), '..'),
stderr: 'pipe', stderr: 'pipe',
env, env,
+76
View File
@@ -2546,4 +2546,80 @@ describe('CDP Session Tests', () => {
await browser.close() await browser.close()
await page.close() await page.close()
}, 60000) }, 60000)
it('editor can list, read, and edit CSS stylesheets', 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, 100))
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!.addStyleTag({
content: `
.editor-test-element {
color: rgb(255, 0, 0);
background-color: rgb(0, 0, 255);
}
`,
})
await new Promise(r => setTimeout(r, 100))
const stylesheets = editor.list({ pattern: /inline-css:/ })
expect(stylesheets.length).toBeGreaterThan(0)
const cssMatches = await editor.grep({ regex: /editor-test-element/, pattern: /inline-css:/ })
expect(cssMatches.length).toBeGreaterThan(0)
const cssMatch = cssMatches[0]
const { content, totalLines } = await editor.read({ url: cssMatch.url })
expect(content).toContain('editor-test-element')
expect(content).toContain('rgb(255, 0, 0)')
expect(totalLines).toBeGreaterThan(0)
await cdpPage!.evaluate(() => {
const el = document.createElement('div')
el.className = 'editor-test-element'
el.id = 'test-div'
el.textContent = 'Test'
document.body.appendChild(el)
})
const colorBefore = await cdpPage!.evaluate(() => {
const el = document.getElementById('test-div')!
return window.getComputedStyle(el).color
})
expect(colorBefore).toBe('rgb(255, 0, 0)')
await editor.edit({
url: cssMatch.url,
oldString: 'color: rgb(255, 0, 0);',
newString: 'color: rgb(0, 255, 0);',
})
const colorAfter = await cdpPage!.evaluate(() => {
const el = document.getElementById('test-div')!
return window.getComputedStyle(el).color
})
expect(colorAfter).toBe('rgb(0, 255, 0)')
cdpSession.close()
await browser.close()
await page.close()
}, 60000)
}) })
+60 -13
View File
@@ -104,8 +104,21 @@ const lastSnapshots: WeakMap<Page, string> = new WeakMap()
const cdpSessionCache: WeakMap<Page, CDPSession> = new WeakMap() const cdpSessionCache: WeakMap<Page, CDPSession> = new WeakMap()
const RELAY_PORT = Number(process.env.PLAYWRITER_PORT) || 19988 const RELAY_PORT = Number(process.env.PLAYWRITER_PORT) || 19988
const REMOTE_URL = process.env.PLAYWRITER_URL
const NO_TABS_ERROR = `No browser tabs are connected. Please install and enable the Playwriter extension on at least one tab: https://chromewebstore.google.com/detail/playwriter-mcp/jfeammnjpkecdekppnclgkkffahnhfhe` const 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`
function parseRemoteUrl() {
if (!REMOTE_URL) {
return null
}
const url = new URL(REMOTE_URL)
return {
host: url.hostname,
port: Number(url.port) || 19988,
query: url.search.slice(1),
}
}
async function setDeviceScaleFactorForMacOS(context: BrowserContext): Promise<void> { async function setDeviceScaleFactorForMacOS(context: BrowserContext): Promise<void> {
if (os.platform() !== 'darwin') { if (os.platform() !== 'darwin') {
return return
@@ -144,9 +157,17 @@ function clearConnectionState() {
state.context = null state.context = null
} }
function getLogServerUrl(): string {
if (REMOTE_URL) {
const url = new URL(REMOTE_URL)
return `http://${url.host}/mcp-log`
}
return `http://127.0.0.1:${RELAY_PORT}/mcp-log`
}
async function sendLogToRelayServer(level: string, ...args: any[]) { async function sendLogToRelayServer(level: string, ...args: any[]) {
try { try {
await fetch(`http://127.0.0.1:${RELAY_PORT}/mcp-log`, { await fetch(getLogServerUrl(), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ level, args }), body: JSON.stringify({ level, args }),
@@ -220,9 +241,12 @@ async function ensureConnection(): Promise<{ browser: Browser; page: Page }> {
return { browser: state.browser, page: state.page } return { browser: state.browser, page: state.page }
} }
await ensureRelayServer() const remote = parseRemoteUrl()
if (!remote) {
await ensureRelayServer()
}
const cdpEndpoint = getCdpUrl({ port: RELAY_PORT }) const cdpEndpoint = getCdpUrl(remote || { port: RELAY_PORT })
const browser = await chromium.connectOverCDP(cdpEndpoint) const browser = await chromium.connectOverCDP(cdpEndpoint)
const contexts = browser.contexts() const contexts = browser.contexts()
@@ -350,9 +374,12 @@ async function resetConnection(): Promise<{ browser: Browser; page: Page; contex
// DO NOT clear browser logs on reset - logs should persist across reconnections // DO NOT clear browser logs on reset - logs should persist across reconnections
// browserLogs.clear() // browserLogs.clear()
await ensureRelayServer() const remote = parseRemoteUrl()
if (!remote) {
await ensureRelayServer()
}
const cdpEndpoint = getCdpUrl({ port: RELAY_PORT }) const cdpEndpoint = getCdpUrl(remote || { port: RELAY_PORT })
const browser = await chromium.connectOverCDP(cdpEndpoint) const browser = await chromium.connectOverCDP(cdpEndpoint)
const contexts = browser.contexts() const contexts = browser.contexts()
@@ -654,7 +681,7 @@ server.tool(
if (cached) { if (cached) {
return cached return cached
} }
const wsUrl = getCdpUrl({ port: RELAY_PORT }) const wsUrl = getCdpUrl(parseRemoteUrl() || { port: RELAY_PORT })
const session = await getCDPSessionForPage({ page: options.page, wsUrl }) const session = await getCDPSessionForPage({ page: options.page, wsUrl })
cdpSessionCache.set(options.page, session) cdpSessionCache.set(options.page, session)
return session return session
@@ -817,13 +844,33 @@ server.tool(
}, },
) )
async function main() { async function checkRemoteServer(url: string): Promise<void> {
await ensureRelayServer() const parsed = new URL(url)
const versionUrl = `http://${parsed.host}/version`
try {
const response = await fetch(versionUrl, { signal: AbortSignal.timeout(3000) })
if (!response.ok) {
throw new Error(`Server responded with status ${response.status}`)
}
} catch (error: any) {
const isConnectionError = error.cause?.code === 'ECONNREFUSED' || error.name === 'TimeoutError'
if (isConnectionError) {
throw new Error(
`Cannot connect to remote relay server at ${parsed.host}. ` +
`Make sure 'npx playwriter serve' is running on the host machine.`,
)
}
throw new Error(`Failed to connect to remote relay server: ${error.message}`)
}
}
export async function startMcp() {
if (!REMOTE_URL) {
await ensureRelayServer()
} else {
console.error(`Using remote CDP relay server: ${REMOTE_URL}`)
await checkRemoteServer(REMOTE_URL)
}
const transport = new StdioServerTransport() const transport = new StdioServerTransport()
await server.connect(transport) await server.connect(transport)
} }
main().catch((error) => {
console.error('Fatal error starting MCP server:', error)
process.exit(1)
})
+14 -5
View File
@@ -112,23 +112,32 @@ you have access to some functions in addition to playwright methods:
// user triggers the code, then: // user triggers the code, then:
if (dbg.isPaused()) { console.log(await dbg.getLocation()); console.log(await dbg.inspectLocalVariables()); 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. - `createEditor({ cdp })`: creates an Editor instance for viewing and live-editing page scripts and CSS stylesheets. Provides a Claude Code-like interface.
- `cdp`: a CDPSession from `getCDPSession` - `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? })` - Methods: `enable()`, `list({ pattern? })`, `read({ url, offset?, limit? })`, `edit({ url, oldString, newString, dryRun? })`, `grep({ regex, pattern? })`, `write({ url, content, dryRun? })`
- Inline scripts (no URL) get synthetic URLs like `inline://42` - use grep() to find them by content - `pattern` parameter: regex to filter URLs (e.g. `/\.js/` for JS files, `/\.css/` for CSS files)
- Inline scripts get `inline://` URLs, inline styles get `inline-css://` URLs - use grep() to find them by content
- Example: - Example:
```js ```js
const cdp = await getCDPSession({ page }); const editor = createEditor({ cdp }); await editor.enable(); const cdp = await getCDPSession({ page }); const editor = createEditor({ cdp }); await editor.enable();
// list available scripts (inline scripts have inline:// URLs) // list all scripts and stylesheets
console.log(editor.list({ search: 'app' })); console.log(editor.list());
// list only JS files
console.log(editor.list({ pattern: /\.js/ }));
// list only CSS files
console.log(editor.list({ pattern: /\.css/ }));
// read a script with line numbers (like Claude Code Read tool) // 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 }); const { content, totalLines } = await editor.read({ url: 'https://example.com/app.js', offset: 0, limit: 50 });
console.log(content); console.log(content);
// edit a script (like Claude Code Edit tool) - exact string replacement // 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' }); await editor.edit({ url: 'https://example.com/app.js', oldString: 'DEBUG = false', newString: 'DEBUG = true' });
// edit CSS
await editor.edit({ url: 'https://example.com/styles.css', oldString: 'color: red', newString: 'color: blue' });
// search across all scripts (like Grep) - useful for finding inline scripts // search across all scripts (like Grep) - useful for finding inline scripts
const matches = await editor.grep({ regex: /myFunction/ }); const matches = await editor.grep({ regex: /myFunction/ });
if (matches.length > 0) { await editor.edit({ url: matches[0].url, oldString: 'return false', newString: 'return true' }); } if (matches.length > 0) { await editor.edit({ url: matches[0].url, oldString: 'return false', newString: 'return true' }); }
// search only in CSS files
const cssMatches = await editor.grep({ regex: /background-color/, pattern: /\.css/ });
``` ```
example: example:
@@ -25,10 +25,10 @@ Return value:
- generic [ref=e18]: Search documentation... - generic [ref=e18]: Search documentation...
- generic [ref=e19]: - generic [ref=e19]:
- generic: ⌘K - generic: ⌘K
- link "103k" [ref=e20] [cursor=pointer]: - link "104k" [ref=e20] [cursor=pointer]:
- /url: https://github.com/shadcn-ui/ui - /url: https://github.com/shadcn-ui/ui
- img - img
- generic [ref=e21]: 103k - generic [ref=e21]: 104k
- button "Toggle theme" [ref=e22]: - button "Toggle theme" [ref=e22]:
- img - img
- generic [ref=e23]: Toggle theme - generic [ref=e23]: Toggle theme
@@ -69,76 +69,76 @@ Return value:
- generic [ref=e50]: Theme - generic [ref=e50]: Theme
- combobox "Theme" [ref=e51]: - combobox "Theme" [ref=e51]:
- generic [ref=e52]: "Theme:" - generic [ref=e52]: "Theme:"
- generic: Neutral
- img - img
- button "Copy Code" [ref=e53]: - combobox [ref=e53]
- button "Copy Code" [ref=e54]:
- img - img
- generic [ref=e54]: Copy Code - generic [ref=e55]: Copy Code
- generic [ref=e58]: - generic [ref=e59]:
- generic [ref=e62]: - generic [ref=e63]:
- group "Payment Method" [ref=e63]: - group "Payment Method" [ref=e64]:
- generic [ref=e64]: Payment Method - generic [ref=e65]: Payment Method
- paragraph [ref=e65]: All transactions are secure and encrypted - paragraph [ref=e66]: All transactions are secure and encrypted
- generic [ref=e66]: - generic [ref=e67]:
- group [ref=e67]: - group [ref=e68]:
- generic [ref=e68]: Name on Card - generic [ref=e69]: Name on Card
- textbox "Name on Card" [ref=e69]: - textbox "Name on Card" [ref=e70]:
- /placeholder: John Doe - /placeholder: John Doe
- generic [ref=e70]: - generic [ref=e71]:
- group [ref=e71]: - group [ref=e72]:
- generic [ref=e72]: Card Number - generic [ref=e73]: Card Number
- textbox "Card Number" [ref=e73]: - textbox "Card Number" [ref=e74]:
- /placeholder: 1234 5678 9012 3456 - /placeholder: 1234 5678 9012 3456
- paragraph [ref=e74]: Enter your 16-digit number. - paragraph [ref=e75]: Enter your 16-digit number.
- group [ref=e75]: - group [ref=e76]:
- generic [ref=e76]: CVV - generic [ref=e77]: CVV
- textbox "CVV" [ref=e77]: - textbox "CVV" [ref=e78]:
- /placeholder: "123" - /placeholder: "123"
- generic [ref=e78]: - generic [ref=e79]:
- group [ref=e79]: - group [ref=e80]:
- generic [ref=e80]: Month - generic [ref=e81]: Month
- combobox "Month" [ref=e81]: - combobox "Month" [ref=e82]:
- generic: MM - generic: MM
- img - img
- combobox [ref=e82] - combobox [ref=e83]
- group [ref=e83]: - group [ref=e84]:
- generic [ref=e84]: Year - generic [ref=e85]: Year
- combobox "Year" [ref=e85]: - combobox "Year" [ref=e86]:
- generic: YYYY - generic: YYYY
- img - img
- combobox [ref=e86] - combobox [ref=e87]
- group "Billing Address" [ref=e88]: - group "Billing Address" [ref=e89]:
- generic [ref=e89]: Billing Address - generic [ref=e90]: Billing Address
- paragraph [ref=e90]: The billing address associated with your payment method - paragraph [ref=e91]: The billing address associated with your payment method
- group [ref=e92]: - group [ref=e93]:
- checkbox "Same as shipping address" [checked] [ref=e93]: - checkbox "Same as shipping address" [checked] [ref=e94]:
- generic: - generic:
- img - img
- checkbox [checked] - checkbox [checked]
- generic [ref=e94]: Same as shipping address - generic [ref=e95]: Same as shipping address
- group [ref=e96]: - group [ref=e97]:
- group [ref=e98]: - group [ref=e99]:
- generic [ref=e99]: Comments - generic [ref=e100]: Comments
- textbox "Comments" [ref=e100]: - textbox "Comments" [ref=e101]:
- /placeholder: Add any additional comments - /placeholder: Add any additional comments
- group [ref=e101]: - group [ref=e102]:
- button "Submit" [ref=e102] - button "Submit" [ref=e103]
- button "Cancel" [ref=e103] - button "Cancel" [ref=e104]
- generic [ref=e104]: - generic [ref=e105]:
- generic [ref=e105]: - generic [ref=e106]:
- generic [ref=e106]: - generic [ref=e107]:
- generic [ref=e108]: - generic [ref=e109]:
- img "@shadcn" [ref=e110] - generic [ref=e111]: CN
- img "@maxleiter" [ref=e112] - generic [ref=e113]: LR
- img "@evilrabbit" [ref=e114] - generic [ref=e115]: ER
- generic [ref=e115]: No Team Members - generic [ref=e116]: No Team Members
- generic [ref=e116]: Invite your team to collaborate on this project. - generic [ref=e117]: Invite your team to collaborate on this project.
- button "Invite Members" [ref=e118]: - button "Invite Members" [ref=e119]:
- img - img
- text: Invite Members - text: Invite Members
- generic [ref=e119]: - generic [ref=e120]:
- generic [ref=e120]: - generic [ref=e121]:
- status "Loading" - status "Loading"
- tex
[Truncated to 6000 characters. Better manage your logs or paginate them to read the full logs] [Truncated to 6000 characters. Better manage your logs or paginate them to read the full logs]
+2 -2
View File
@@ -20,8 +20,8 @@ process.on('exit', async (code) => {
}); });
export async function startServer({ port = 19988 }: { port?: number } = {}) { export async function startServer({ port = 19988, host = '127.0.0.1', token }: { port?: number; host?: string; token?: string } = {}) {
const server = await startPlayWriterCDPRelayServer({ port, logger }) const server = await startPlayWriterCDPRelayServer({ port, host, token, logger })
console.log('CDP Relay Server running. Press Ctrl+C to stop.') console.log('CDP Relay Server running. Press Ctrl+C to stop.')
console.log('Logs are being written to:', logger.logFilePath) console.log('Logs are being written to:', logger.logFilePath)
+3 -2
View File
@@ -3,9 +3,10 @@ import os from 'node:os'
import path from 'node:path' import path from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
export function getCdpUrl({ port = 19988, host = '127.0.0.1' }: { port?: number; host?: string } = {}) { export function getCdpUrl({ port = 19988, host = '127.0.0.1', query }: { port?: number; host?: string; query?: string } = {}) {
const id = `${Math.random().toString(36).substring(2, 15)}_${Date.now()}` const id = `${Math.random().toString(36).substring(2, 15)}_${Date.now()}`
return `ws://${host}:${port}/cdp/${id}` const queryString = query ? `?${query}` : ''
return `ws://${host}:${port}/cdp/${id}${queryString}`
} }
export const LOG_FILE_PATH = path.join(os.tmpdir(), 'playwriter', 'relay-server.log') export const LOG_FILE_PATH = path.join(os.tmpdir(), 'playwriter', 'relay-server.log')
+3
View File
@@ -69,6 +69,9 @@ importers:
'@modelcontextprotocol/sdk': '@modelcontextprotocol/sdk':
specifier: ^1.21.1 specifier: ^1.21.1
version: 1.21.1(@cfworker/json-schema@4.1.1) version: 1.21.1(@cfworker/json-schema@4.1.1)
cac:
specifier: ^6.7.14
version: 6.7.14
chalk: chalk:
specifier: ^5.6.2 specifier: ^5.6.2
version: 5.6.2 version: 5.6.2