From e4695e231d80ecaae7729842eaa2a1b2c9d953bc Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Sat, 27 Dec 2025 18:48:52 +0100 Subject: [PATCH] add support for remote agent in different host than extension. add support for editing css. --- README.md | 18 ++ playwriter/bin.js | 2 +- playwriter/package.json | 1 + playwriter/src/cli.ts | 73 ++++++ playwriter/src/editor-examples.ts | 50 +++- playwriter/src/editor.ts | 237 ++++++++++-------- playwriter/src/extension/cdp-relay.ts | 13 +- playwriter/src/mcp-client.ts | 2 +- playwriter/src/mcp.test.ts | 76 ++++++ playwriter/src/mcp.ts | 73 +++++- playwriter/src/prompt.md | 19 +- .../src/snapshots/shadcn-ui-accessibility.md | 114 ++++----- playwriter/src/start-relay-server.ts | 4 +- playwriter/src/utils.ts | 5 +- pnpm-lock.yaml | 3 + 15 files changed, 495 insertions(+), 195 deletions(-) create mode 100644 playwriter/src/cli.ts diff --git a/README.md b/README.md index 2cfe0ab..d9e30a3 100644 --- a/README.md +++ b/README.md @@ -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 +``` + +**In container/VM (where agent runs):** + +```bash +export PLAYWRITER_URL="ws://host.docker.internal:19988?token=" +``` + +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. diff --git a/playwriter/bin.js b/playwriter/bin.js index d5d0852..0756f5c 100755 --- a/playwriter/bin.js +++ b/playwriter/bin.js @@ -1,3 +1,3 @@ #!/usr/bin/env node -import './dist/mcp.js' +import './dist/cli.js' diff --git a/playwriter/package.json b/playwriter/package.json index 75e729f..284772e 100644 --- a/playwriter/package.json +++ b/playwriter/package.json @@ -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", diff --git a/playwriter/src/cli.ts b/playwriter/src/cli.ts new file mode 100644 index 0000000..6d6fe64 --- /dev/null +++ b/playwriter/src/cli.ts @@ -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 to bind to', { default: '0.0.0.0' }) + .option('--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/${options.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() diff --git a/playwriter/src/editor-examples.ts b/playwriter/src/editor-examples.ts index cc9e95d..662e2f8 100644 --- a/playwriter/src/editor-examples.ts +++ b/playwriter/src/editor-examples.ts @@ -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 } diff --git a/playwriter/src/editor.ts b/playwriter/src/editor.ts index 31c0aed..c0f9e34 100644 --- a/playwriter/src/editor.ts +++ b/playwriter/src/editor.ts @@ -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() - private scriptsByUrl = new Map() + private scripts = new Map() + private stylesheets = new Map() private sourceCache = new Map() 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 { 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 { + 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 { 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 { + 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 { + async grep({ regex, pattern }: { regex: RegExp; pattern?: RegExp }): Promise { 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 { 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) } } diff --git a/playwriter/src/extension/cdp-relay.ts b/playwriter/src/extension/cdp-relay.ts index 1e0e6fb..e763e97 100644 --- a/playwriter/src/extension/cdp-relay.ts +++ b/playwriter/src/extension/cdp-relay.ts @@ -29,7 +29,7 @@ export type RelayServer = { off(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 { +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 { const emitter = new EventEmitter() const connectedTargets = new Map() @@ -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 { diff --git a/playwriter/src/mcp-client.ts b/playwriter/src/mcp-client.ts index 274874b..a6584bd 100644 --- a/playwriter/src/mcp-client.ts +++ b/playwriter/src/mcp-client.ts @@ -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, diff --git a/playwriter/src/mcp.test.ts b/playwriter/src/mcp.test.ts index 6ad2ce8..1021fa9 100644 --- a/playwriter/src/mcp.test.ts +++ b/playwriter/src/mcp.test.ts @@ -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) }) diff --git a/playwriter/src/mcp.ts b/playwriter/src/mcp.ts index d0b436e..bf59cbc 100644 --- a/playwriter/src/mcp.ts +++ b/playwriter/src/mcp.ts @@ -104,8 +104,21 @@ const lastSnapshots: WeakMap = new WeakMap() const cdpSessionCache: WeakMap = 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 { 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 { + 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) -}) diff --git a/playwriter/src/prompt.md b/playwriter/src/prompt.md index 2d77d1a..541c7b9 100644 --- a/playwriter/src/prompt.md +++ b/playwriter/src/prompt.md @@ -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: diff --git a/playwriter/src/snapshots/shadcn-ui-accessibility.md b/playwriter/src/snapshots/shadcn-ui-accessibility.md index f0c9c50..f6b8a17 100644 --- a/playwriter/src/snapshots/shadcn-ui-accessibility.md +++ b/playwriter/src/snapshots/shadcn-ui-accessibility.md @@ -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] \ No newline at end of file diff --git a/playwriter/src/start-relay-server.ts b/playwriter/src/start-relay-server.ts index e02b61c..4aea6a1 100644 --- a/playwriter/src/start-relay-server.ts +++ b/playwriter/src/start-relay-server.ts @@ -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) diff --git a/playwriter/src/utils.ts b/playwriter/src/utils.ts index d375d1c..67134aa 100644 --- a/playwriter/src/utils.ts +++ b/playwriter/src/utils.ts @@ -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') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d8a3a0..0092f9b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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