add support for remote agent in different host than extension. add
support for editing css.
This commit is contained in:
@@ -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
|
||||
|
||||
Playwriter is designed with security in mind, ensuring that only you can control your browser.
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import './dist/mcp.js'
|
||||
import './dist/cli.js'
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"@hono/node-server": "^1.19.6",
|
||||
"@hono/node-ws": "^1.2.0",
|
||||
"@modelcontextprotocol/sdk": "^1.21.1",
|
||||
"cac": "^6.7.14",
|
||||
"chalk": "^5.6.2",
|
||||
"devtools-protocol": "^0.0.1543509",
|
||||
"diff": "^8.0.2",
|
||||
|
||||
@@ -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()
|
||||
@@ -6,7 +6,7 @@ async function listScripts() {
|
||||
const editor = createEditor({ cdp })
|
||||
await editor.enable()
|
||||
|
||||
const scripts = editor.list({ search: 'app' })
|
||||
const scripts = editor.list({ pattern: /app/ })
|
||||
console.log(scripts)
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ async function searchScripts() {
|
||||
|
||||
const todoMatches = await editor.grep({
|
||||
regex: /TODO|FIXME/i,
|
||||
include: 'app',
|
||||
pattern: /app/,
|
||||
})
|
||||
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
@@ -1,10 +1,5 @@
|
||||
import type { CDPSession } from './cdp-session.js'
|
||||
|
||||
export interface ScriptInfo {
|
||||
url: string
|
||||
scriptId: string
|
||||
}
|
||||
|
||||
export interface ReadResult {
|
||||
content: string
|
||||
totalLines: number
|
||||
@@ -53,8 +48,8 @@ export interface EditResult {
|
||||
export class Editor {
|
||||
private cdp: CDPSession
|
||||
private enabled = false
|
||||
private scripts = new Map<string, ScriptInfo>()
|
||||
private scriptsByUrl = new Map<string, ScriptInfo>()
|
||||
private scripts = new Map<string, string>()
|
||||
private stylesheets = new Map<string, string>()
|
||||
private sourceCache = new Map<string, string>()
|
||||
|
||||
constructor({ cdp }: { cdp: CDPSession }) {
|
||||
@@ -66,15 +61,20 @@ export class Editor {
|
||||
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.scripts.set(url, 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
|
||||
}
|
||||
await this.cdp.send('Debugger.enable')
|
||||
await this.cdp.send('DOM.enable')
|
||||
await this.cdp.send('CSS.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 ? '...' : ''}`)
|
||||
private getIdByUrl(url: string): { scriptId: string } | { styleSheetId: string } {
|
||||
const scriptId = this.scripts.get(url)
|
||||
if (scriptId) {
|
||||
return { scriptId }
|
||||
}
|
||||
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.search - Optional substring to filter URLs (case-insensitive)
|
||||
* @returns Array of scripts with url and scriptId
|
||||
* @param options.pattern - Optional regex to filter URLs
|
||||
* @returns Array of URLs
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // List all scripts
|
||||
* const scripts = editor.list()
|
||||
* // List all scripts and stylesheets
|
||||
* 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
|
||||
* const appScripts = editor.list({ search: 'app' })
|
||||
* const reactScripts = editor.list({ search: 'react' })
|
||||
* const appScripts = editor.list({ pattern: /app/ })
|
||||
* ```
|
||||
*/
|
||||
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
|
||||
list({ pattern }: { pattern?: RegExp } = {}): string[] {
|
||||
const urls = [...Array.from(this.scripts.keys()), ...Array.from(this.stylesheets.keys())]
|
||||
|
||||
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.
|
||||
* 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.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.limit - Number of lines to return (default 2000)
|
||||
* @returns Content with line numbers, total lines, and range info
|
||||
@@ -140,8 +159,8 @@ export class Editor {
|
||||
* url: 'https://example.com/app.js'
|
||||
* })
|
||||
*
|
||||
* // Read inline script (URL from grep result)
|
||||
* const { content } = await editor.read({ url: 'inline://42' })
|
||||
* // Read a CSS file
|
||||
* const { content } = await editor.read({ url: 'https://example.com/styles.css' })
|
||||
*
|
||||
* // Read lines 100-200
|
||||
* 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> {
|
||||
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 id = this.getIdByUrl(url)
|
||||
const source = await this.getSource(id)
|
||||
|
||||
const lines = source.split('\n')
|
||||
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.
|
||||
*
|
||||
* @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.newString - Replacement string
|
||||
* @param options.dryRun - If true, validate without applying (default false)
|
||||
@@ -191,18 +223,18 @@ export class Editor {
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Replace a string
|
||||
* // Replace a string in JS
|
||||
* await editor.edit({
|
||||
* url: 'https://example.com/app.js',
|
||||
* oldString: 'const DEBUG = false',
|
||||
* newString: 'const DEBUG = true'
|
||||
* })
|
||||
*
|
||||
* // Edit inline script
|
||||
* // Edit CSS
|
||||
* await editor.edit({
|
||||
* url: 'inline://42',
|
||||
* oldString: 'old code',
|
||||
* newString: 'new code'
|
||||
* url: 'https://example.com/styles.css',
|
||||
* oldString: 'color: red',
|
||||
* newString: 'color: blue'
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
@@ -218,14 +250,8 @@ export class Editor {
|
||||
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 id = this.getIdByUrl(url)
|
||||
const source = await this.getSource(id)
|
||||
|
||||
const matchCount = source.split(oldString).length - 1
|
||||
if (matchCount === 0) {
|
||||
@@ -236,72 +262,79 @@ export class Editor {
|
||||
}
|
||||
|
||||
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', {
|
||||
scriptId: script.scriptId,
|
||||
scriptSource: newSource,
|
||||
scriptId: id.scriptId,
|
||||
scriptSource: content,
|
||||
dryRun,
|
||||
})
|
||||
|
||||
if (!dryRun) {
|
||||
this.sourceCache.set(script.scriptId, newSource)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
stackChanged: response.stackChanged,
|
||||
this.sourceCache.set(id.scriptId, content)
|
||||
}
|
||||
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.
|
||||
*
|
||||
* @param options - Options
|
||||
* @param options.regex - Regular expression to search for
|
||||
* @param options.include - Optional URL substring to filter which scripts to search
|
||||
* @param options.regex - Regular expression to search for in file contents
|
||||
* @param options.pattern - Optional regex to filter which URLs 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 all scripts and stylesheets for "color"
|
||||
* const matches = await editor.grep({ regex: /color/ })
|
||||
*
|
||||
* // Search only in app scripts
|
||||
* // Search only CSS files
|
||||
* const matches = await editor.grep({
|
||||
* regex: /TODO/i,
|
||||
* include: 'app'
|
||||
* regex: /background-color/,
|
||||
* pattern: /\.css/
|
||||
* })
|
||||
*
|
||||
* // Regex search for console methods
|
||||
* // Regex search for console methods in JS
|
||||
* 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()
|
||||
|
||||
const matches: SearchMatch[] = []
|
||||
const scripts = include ? this.list({ search: include }) : this.list()
|
||||
const urls = this.list({ pattern })
|
||||
|
||||
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
|
||||
}
|
||||
for (const url of urls) {
|
||||
let source: string
|
||||
try {
|
||||
const id = this.getIdByUrl(url)
|
||||
source = await this.getSource(id)
|
||||
} 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,
|
||||
url,
|
||||
lineNumber: i + 1,
|
||||
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.
|
||||
*
|
||||
* @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)
|
||||
* @param options.url - Script or stylesheet URL (inline scripts have `inline://{id}` URLs)
|
||||
* @param options.content - New content
|
||||
* @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> {
|
||||
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,
|
||||
}
|
||||
const id = this.getIdByUrl(url)
|
||||
return this.setSource(id, content, dryRun)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export type RelayServer = {
|
||||
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 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'
|
||||
|
||||
return {
|
||||
|
||||
@@ -27,7 +27,7 @@ export async function createTransport({ args = [], port }: { args?: string[]; po
|
||||
}
|
||||
const transport = new StdioClientTransport({
|
||||
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), '..'),
|
||||
stderr: 'pipe',
|
||||
env,
|
||||
|
||||
@@ -2546,4 +2546,80 @@ describe('CDP Session Tests', () => {
|
||||
await browser.close()
|
||||
await page.close()
|
||||
}, 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
@@ -104,8 +104,21 @@ const lastSnapshots: WeakMap<Page, string> = new WeakMap()
|
||||
const cdpSessionCache: WeakMap<Page, CDPSession> = new WeakMap()
|
||||
|
||||
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`
|
||||
|
||||
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> {
|
||||
if (os.platform() !== 'darwin') {
|
||||
return
|
||||
@@ -144,9 +157,17 @@ function clearConnectionState() {
|
||||
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[]) {
|
||||
try {
|
||||
await fetch(`http://127.0.0.1:${RELAY_PORT}/mcp-log`, {
|
||||
await fetch(getLogServerUrl(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ level, args }),
|
||||
@@ -220,9 +241,12 @@ async function ensureConnection(): Promise<{ browser: Browser; page: 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 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
|
||||
// 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 contexts = browser.contexts()
|
||||
@@ -654,7 +681,7 @@ server.tool(
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
const wsUrl = getCdpUrl({ port: RELAY_PORT })
|
||||
const wsUrl = getCdpUrl(parseRemoteUrl() || { port: RELAY_PORT })
|
||||
const session = await getCDPSessionForPage({ page: options.page, wsUrl })
|
||||
cdpSessionCache.set(options.page, session)
|
||||
return session
|
||||
@@ -817,13 +844,33 @@ server.tool(
|
||||
},
|
||||
)
|
||||
|
||||
async function main() {
|
||||
await ensureRelayServer()
|
||||
async function checkRemoteServer(url: string): Promise<void> {
|
||||
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()
|
||||
await server.connect(transport)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Fatal error starting MCP server:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
@@ -112,23 +112,32 @@ you have access to some functions in addition to playwright methods:
|
||||
// user triggers the code, then:
|
||||
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`
|
||||
- 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
|
||||
- Methods: `enable()`, `list({ pattern? })`, `read({ url, offset?, limit? })`, `edit({ url, oldString, newString, dryRun? })`, `grep({ regex, pattern? })`, `write({ url, content, dryRun? })`
|
||||
- `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:
|
||||
```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' }));
|
||||
// list all scripts and stylesheets
|
||||
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)
|
||||
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' });
|
||||
// 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
|
||||
const matches = await editor.grep({ regex: /myFunction/ });
|
||||
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:
|
||||
|
||||
@@ -25,10 +25,10 @@ Return value:
|
||||
- generic [ref=e18]: Search documentation...
|
||||
- generic [ref=e19]:
|
||||
- generic: ⌘K
|
||||
- link "103k" [ref=e20] [cursor=pointer]:
|
||||
- link "104k" [ref=e20] [cursor=pointer]:
|
||||
- /url: https://github.com/shadcn-ui/ui
|
||||
- img
|
||||
- generic [ref=e21]: 103k
|
||||
- generic [ref=e21]: 104k
|
||||
- button "Toggle theme" [ref=e22]:
|
||||
- img
|
||||
- generic [ref=e23]: Toggle theme
|
||||
@@ -69,76 +69,76 @@ Return value:
|
||||
- generic [ref=e50]: Theme
|
||||
- combobox "Theme" [ref=e51]:
|
||||
- generic [ref=e52]: "Theme:"
|
||||
- generic: Neutral
|
||||
- img
|
||||
- button "Copy Code" [ref=e53]:
|
||||
- combobox [ref=e53]
|
||||
- button "Copy Code" [ref=e54]:
|
||||
- img
|
||||
- generic [ref=e54]: Copy Code
|
||||
- generic [ref=e58]:
|
||||
- generic [ref=e62]:
|
||||
- group "Payment Method" [ref=e63]:
|
||||
- generic [ref=e64]: Payment Method
|
||||
- paragraph [ref=e65]: All transactions are secure and encrypted
|
||||
- generic [ref=e66]:
|
||||
- group [ref=e67]:
|
||||
- generic [ref=e68]: Name on Card
|
||||
- textbox "Name on Card" [ref=e69]:
|
||||
- generic [ref=e55]: Copy Code
|
||||
- generic [ref=e59]:
|
||||
- generic [ref=e63]:
|
||||
- group "Payment Method" [ref=e64]:
|
||||
- generic [ref=e65]: Payment Method
|
||||
- paragraph [ref=e66]: All transactions are secure and encrypted
|
||||
- generic [ref=e67]:
|
||||
- group [ref=e68]:
|
||||
- generic [ref=e69]: Name on Card
|
||||
- textbox "Name on Card" [ref=e70]:
|
||||
- /placeholder: John Doe
|
||||
- generic [ref=e70]:
|
||||
- group [ref=e71]:
|
||||
- generic [ref=e72]: Card Number
|
||||
- textbox "Card Number" [ref=e73]:
|
||||
- generic [ref=e71]:
|
||||
- group [ref=e72]:
|
||||
- generic [ref=e73]: Card Number
|
||||
- textbox "Card Number" [ref=e74]:
|
||||
- /placeholder: 1234 5678 9012 3456
|
||||
- paragraph [ref=e74]: Enter your 16-digit number.
|
||||
- group [ref=e75]:
|
||||
- generic [ref=e76]: CVV
|
||||
- textbox "CVV" [ref=e77]:
|
||||
- paragraph [ref=e75]: Enter your 16-digit number.
|
||||
- group [ref=e76]:
|
||||
- generic [ref=e77]: CVV
|
||||
- textbox "CVV" [ref=e78]:
|
||||
- /placeholder: "123"
|
||||
- generic [ref=e78]:
|
||||
- group [ref=e79]:
|
||||
- generic [ref=e80]: Month
|
||||
- combobox "Month" [ref=e81]:
|
||||
- generic [ref=e79]:
|
||||
- group [ref=e80]:
|
||||
- generic [ref=e81]: Month
|
||||
- combobox "Month" [ref=e82]:
|
||||
- generic: MM
|
||||
- img
|
||||
- combobox [ref=e82]
|
||||
- group [ref=e83]:
|
||||
- generic [ref=e84]: Year
|
||||
- combobox "Year" [ref=e85]:
|
||||
- combobox [ref=e83]
|
||||
- group [ref=e84]:
|
||||
- generic [ref=e85]: Year
|
||||
- combobox "Year" [ref=e86]:
|
||||
- generic: YYYY
|
||||
- img
|
||||
- combobox [ref=e86]
|
||||
- group "Billing Address" [ref=e88]:
|
||||
- generic [ref=e89]: Billing Address
|
||||
- paragraph [ref=e90]: The billing address associated with your payment method
|
||||
- group [ref=e92]:
|
||||
- checkbox "Same as shipping address" [checked] [ref=e93]:
|
||||
- combobox [ref=e87]
|
||||
- group "Billing Address" [ref=e89]:
|
||||
- generic [ref=e90]: Billing Address
|
||||
- paragraph [ref=e91]: The billing address associated with your payment method
|
||||
- group [ref=e93]:
|
||||
- checkbox "Same as shipping address" [checked] [ref=e94]:
|
||||
- generic:
|
||||
- img
|
||||
- checkbox [checked]
|
||||
- generic [ref=e94]: Same as shipping address
|
||||
- group [ref=e96]:
|
||||
- group [ref=e98]:
|
||||
- generic [ref=e99]: Comments
|
||||
- textbox "Comments" [ref=e100]:
|
||||
- generic [ref=e95]: Same as shipping address
|
||||
- group [ref=e97]:
|
||||
- group [ref=e99]:
|
||||
- generic [ref=e100]: Comments
|
||||
- textbox "Comments" [ref=e101]:
|
||||
- /placeholder: Add any additional comments
|
||||
- group [ref=e101]:
|
||||
- button "Submit" [ref=e102]
|
||||
- button "Cancel" [ref=e103]
|
||||
- generic [ref=e104]:
|
||||
- generic [ref=e105]:
|
||||
- generic [ref=e106]:
|
||||
- generic [ref=e108]:
|
||||
- img "@shadcn" [ref=e110]
|
||||
- img "@maxleiter" [ref=e112]
|
||||
- img "@evilrabbit" [ref=e114]
|
||||
- generic [ref=e115]: No Team Members
|
||||
- generic [ref=e116]: Invite your team to collaborate on this project.
|
||||
- button "Invite Members" [ref=e118]:
|
||||
- group [ref=e102]:
|
||||
- button "Submit" [ref=e103]
|
||||
- button "Cancel" [ref=e104]
|
||||
- generic [ref=e105]:
|
||||
- generic [ref=e106]:
|
||||
- generic [ref=e107]:
|
||||
- generic [ref=e109]:
|
||||
- generic [ref=e111]: CN
|
||||
- generic [ref=e113]: LR
|
||||
- generic [ref=e115]: ER
|
||||
- generic [ref=e116]: No Team Members
|
||||
- generic [ref=e117]: Invite your team to collaborate on this project.
|
||||
- button "Invite Members" [ref=e119]:
|
||||
- img
|
||||
- text: Invite Members
|
||||
- generic [ref=e119]:
|
||||
- generic [ref=e120]:
|
||||
- generic [ref=e120]:
|
||||
- generic [ref=e121]:
|
||||
- status "Loading"
|
||||
|
||||
- tex
|
||||
|
||||
[Truncated to 6000 characters. Better manage your logs or paginate them to read the full logs]
|
||||
@@ -20,8 +20,8 @@ process.on('exit', async (code) => {
|
||||
});
|
||||
|
||||
|
||||
export async function startServer({ port = 19988 }: { port?: number } = {}) {
|
||||
const server = await startPlayWriterCDPRelayServer({ port, logger })
|
||||
export async function startServer({ port = 19988, host = '127.0.0.1', token }: { port?: number; host?: string; token?: string } = {}) {
|
||||
const server = await startPlayWriterCDPRelayServer({ port, host, token, logger })
|
||||
|
||||
console.log('CDP Relay Server running. Press Ctrl+C to stop.')
|
||||
console.log('Logs are being written to:', logger.logFilePath)
|
||||
|
||||
@@ -3,9 +3,10 @@ import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
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()}`
|
||||
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')
|
||||
|
||||
Generated
+3
@@ -69,6 +69,9 @@ importers:
|
||||
'@modelcontextprotocol/sdk':
|
||||
specifier: ^1.21.1
|
||||
version: 1.21.1(@cfworker/json-schema@4.1.1)
|
||||
cac:
|
||||
specifier: ^6.7.14
|
||||
version: 6.7.14
|
||||
chalk:
|
||||
specifier: ^5.6.2
|
||||
version: 5.6.2
|
||||
|
||||
Reference in New Issue
Block a user