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
|
## 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
@@ -1,3 +1,3 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import './dist/mcp.js'
|
import './dist/cli.js'
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 })
|
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
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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)
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -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,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')
|
||||||
|
|||||||
Generated
+3
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user