feat: add CDP discovery endpoints (/json/version, /json/list)
Standard Chrome DevTools Protocol HTTP discovery endpoints that allow tools like Playwright to connect using just the HTTP URL without needing to call getCdpUrl() first. - /json/version returns browser info and webSocketDebuggerUrl - /json/list returns array of connected targets - Supports GET/PUT methods and trailing slashes for compatibility
This commit is contained in:
@@ -90,6 +90,25 @@ await browser.close()
|
||||
server.close()
|
||||
```
|
||||
|
||||
### Standard CDP Connection
|
||||
|
||||
Start the relay server, then connect with just the HTTP URL:
|
||||
|
||||
```bash
|
||||
npx -y playwriter serve --host 127.0.0.1
|
||||
```
|
||||
|
||||
Or programmatically:
|
||||
|
||||
```typescript
|
||||
import { startPlayWriterCDPRelayServer } from 'playwriter'
|
||||
|
||||
await startPlayWriterCDPRelayServer()
|
||||
const browser = await chromium.connectOverCDP('http://127.0.0.1:19988')
|
||||
```
|
||||
|
||||
This works with any CDP-compatible tool - no special configuration needed.
|
||||
|
||||
### Visual Aria Ref Labels
|
||||
|
||||
Playwriter includes Vimium-style visual labels that overlay interactive elements, making it easy for AI agents to identify and click elements from screenshots. The `screenshotWithAccessibilityLabels` function is available in the MCP context.
|
||||
|
||||
@@ -8,6 +8,14 @@
|
||||
- Non-contiguous sections are separated by `---`
|
||||
- Provides better context for understanding search results
|
||||
|
||||
- **CDP discovery endpoints**: Added standard Chrome DevTools Protocol HTTP discovery endpoints
|
||||
- `/json/version` - Returns browser info and `webSocketDebuggerUrl`
|
||||
- `/json/list` - Returns list of debuggable targets
|
||||
- `/json` - Alias for `/json/list`
|
||||
- Supports both GET and PUT methods (Chrome 66+ compatibility)
|
||||
- Handles trailing slash variants (Playwright compatibility)
|
||||
- Allows `chromium.connectOverCDP('http://127.0.0.1:19988')` without needing to call `getCdpUrl` first
|
||||
|
||||
## 0.0.43
|
||||
|
||||
### Features
|
||||
|
||||
@@ -363,6 +363,13 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
||||
const app = new Hono()
|
||||
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app })
|
||||
|
||||
// Helper to build WebSocket URL from request host
|
||||
const getCdpWsUrl = (reqHost: string | undefined) => {
|
||||
const hostHeader = reqHost || `${host}:${port}`
|
||||
const wsUrl = `ws://${hostHeader}/cdp`
|
||||
return token ? `${wsUrl}?token=${token}` : wsUrl
|
||||
}
|
||||
|
||||
app.get('/', (c) => {
|
||||
return c.text('OK')
|
||||
})
|
||||
@@ -375,6 +382,82 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
||||
return c.json({ connected: extensionWs !== null })
|
||||
})
|
||||
|
||||
// CDP Discovery Endpoints - Standard Chrome DevTools Protocol HTTP API
|
||||
// These allow tools like Playwright to discover the WebSocket URL automatically
|
||||
// Spec: https://chromium.googlesource.com/chromium/src/+/main/content/browser/devtools/devtools_http_handler.cc
|
||||
|
||||
app
|
||||
.on(['GET', 'PUT'], '/json/version', (c) => {
|
||||
return c.json({
|
||||
'Browser': `Playwriter/${VERSION}`,
|
||||
'Protocol-Version': '1.3',
|
||||
'webSocketDebuggerUrl': getCdpWsUrl(c.req.header('host'))
|
||||
})
|
||||
})
|
||||
.on(['GET', 'PUT'], '/json/version/', (c) => {
|
||||
return c.json({
|
||||
'Browser': `Playwriter/${VERSION}`,
|
||||
'Protocol-Version': '1.3',
|
||||
'webSocketDebuggerUrl': getCdpWsUrl(c.req.header('host'))
|
||||
})
|
||||
})
|
||||
.on(['GET', 'PUT'], '/json/list', (c) => {
|
||||
const wsUrl = getCdpWsUrl(c.req.header('host'))
|
||||
return c.json(
|
||||
Array.from(connectedTargets.values()).map(t => ({
|
||||
id: t.targetId,
|
||||
type: t.targetInfo.type,
|
||||
title: t.targetInfo.title,
|
||||
description: t.targetInfo.title,
|
||||
url: t.targetInfo.url,
|
||||
webSocketDebuggerUrl: wsUrl,
|
||||
devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`
|
||||
}))
|
||||
)
|
||||
})
|
||||
.on(['GET', 'PUT'], '/json/list/', (c) => {
|
||||
const wsUrl = getCdpWsUrl(c.req.header('host'))
|
||||
return c.json(
|
||||
Array.from(connectedTargets.values()).map(t => ({
|
||||
id: t.targetId,
|
||||
type: t.targetInfo.type,
|
||||
title: t.targetInfo.title,
|
||||
description: t.targetInfo.title,
|
||||
url: t.targetInfo.url,
|
||||
webSocketDebuggerUrl: wsUrl,
|
||||
devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`
|
||||
}))
|
||||
)
|
||||
})
|
||||
.on(['GET', 'PUT'], '/json', (c) => {
|
||||
const wsUrl = getCdpWsUrl(c.req.header('host'))
|
||||
return c.json(
|
||||
Array.from(connectedTargets.values()).map(t => ({
|
||||
id: t.targetId,
|
||||
type: t.targetInfo.type,
|
||||
title: t.targetInfo.title,
|
||||
description: t.targetInfo.title,
|
||||
url: t.targetInfo.url,
|
||||
webSocketDebuggerUrl: wsUrl,
|
||||
devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`
|
||||
}))
|
||||
)
|
||||
})
|
||||
.on(['GET', 'PUT'], '/json/', (c) => {
|
||||
const wsUrl = getCdpWsUrl(c.req.header('host'))
|
||||
return c.json(
|
||||
Array.from(connectedTargets.values()).map(t => ({
|
||||
id: t.targetId,
|
||||
type: t.targetInfo.type,
|
||||
title: t.targetInfo.title,
|
||||
description: t.targetInfo.title,
|
||||
url: t.targetInfo.url,
|
||||
webSocketDebuggerUrl: wsUrl,
|
||||
devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`
|
||||
}))
|
||||
)
|
||||
})
|
||||
|
||||
app.post('/mcp-log', async (c) => {
|
||||
try {
|
||||
const { level, args } = await c.req.json()
|
||||
|
||||
@@ -2527,6 +2527,65 @@ describe('MCP Server Tests', () => {
|
||||
await page.close()
|
||||
}, 60000)
|
||||
|
||||
it('should expose CDP discovery endpoints /json/version and /json/list', async () => {
|
||||
const browserContext = getBrowserContext()
|
||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||
|
||||
// Enable extension on a page
|
||||
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, 200))
|
||||
|
||||
// Test /json/version
|
||||
const versionRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json/version`)
|
||||
expect(versionRes.status).toBe(200)
|
||||
const versionJson = await versionRes.json() as { webSocketDebuggerUrl: string }
|
||||
expect(versionJson).toMatchObject({
|
||||
'Browser': expect.stringContaining('Playwriter/'),
|
||||
'Protocol-Version': '1.3',
|
||||
'webSocketDebuggerUrl': expect.stringContaining('ws://'),
|
||||
})
|
||||
expect(versionJson.webSocketDebuggerUrl).toContain(`127.0.0.1:${TEST_PORT}/cdp`)
|
||||
|
||||
// Test /json/version/ (trailing slash - Playwright uses this)
|
||||
const versionSlashRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json/version/`)
|
||||
expect(versionSlashRes.status).toBe(200)
|
||||
|
||||
// Test /json/list
|
||||
const listRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json/list`)
|
||||
expect(listRes.status).toBe(200)
|
||||
const listJson = await listRes.json() as Array<{ url?: string }>
|
||||
expect(Array.isArray(listJson)).toBe(true)
|
||||
expect(listJson.length).toBeGreaterThan(0)
|
||||
|
||||
// Find the example.com page (there may be other pages like about:blank)
|
||||
const examplePage = listJson.find((t) => t.url?.includes('example.com'))
|
||||
expect(examplePage).toBeDefined()
|
||||
expect(examplePage).toMatchObject({
|
||||
id: expect.any(String),
|
||||
type: 'page',
|
||||
url: expect.stringContaining('example.com'),
|
||||
webSocketDebuggerUrl: expect.stringContaining('ws://'),
|
||||
})
|
||||
|
||||
// Test /json (alias for /json/list)
|
||||
const jsonRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json`)
|
||||
expect(jsonRes.status).toBe(200)
|
||||
const jsonData = await jsonRes.json()
|
||||
expect(Array.isArray(jsonData)).toBe(true)
|
||||
|
||||
// Test PUT method (Chrome 66+ prefers PUT)
|
||||
const putRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json/version`, { method: 'PUT' })
|
||||
expect(putRes.status).toBe(200)
|
||||
|
||||
await page.close()
|
||||
}, 60000)
|
||||
|
||||
})
|
||||
|
||||
|
||||
@@ -3465,6 +3524,76 @@ describe('CDP Session Tests', () => {
|
||||
}, 60000)
|
||||
})
|
||||
|
||||
describe('Service Worker Target Tests', () => {
|
||||
let testCtx: TestContext | null = null
|
||||
|
||||
beforeAll(async () => {
|
||||
testCtx = await setupTestContext({ tempDirPrefix: 'pw-sw-test-' })
|
||||
}, 600000)
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestContext(testCtx)
|
||||
testCtx = null
|
||||
})
|
||||
|
||||
const getBrowserContext = () => {
|
||||
if (!testCtx?.browserContext) throw new Error('Browser not initialized')
|
||||
return testCtx.browserContext
|
||||
}
|
||||
|
||||
it('should not disconnect when page has service worker (issue #14)', async () => {
|
||||
// This test reproduces issue #14: pages with service workers cause disconnection loops.
|
||||
// The problem is that Target.setAutoAttach attaches to service workers, and when
|
||||
// Playwright tries to enable Network/Runtime on the SW session, the extension can't
|
||||
// find a matching tab, causing errors and disconnection.
|
||||
|
||||
const browserContext = getBrowserContext()
|
||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||
|
||||
// Discord has a service worker - navigate there
|
||||
const page = await browserContext.newPage()
|
||||
await page.goto('https://discord.com/login', { waitUntil: 'load' })
|
||||
await page.bringToFront()
|
||||
|
||||
// Attach extension to the page
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
|
||||
// Connect via Playwright CDP - this triggers Target.setAutoAttach which
|
||||
// will also attach to the service worker
|
||||
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
|
||||
|
||||
// Track disconnection events
|
||||
let disconnected = false
|
||||
browser.on('disconnected', () => {
|
||||
disconnected = true
|
||||
console.log('Browser disconnected!')
|
||||
})
|
||||
|
||||
// Wait for potential disconnection - issue says ~10s loop
|
||||
// We wait 15 seconds to be sure
|
||||
console.log('Waiting 15 seconds to check for disconnection loop...')
|
||||
await new Promise(r => setTimeout(r, 15000))
|
||||
|
||||
// Should still be connected - this is what we're testing
|
||||
expect(disconnected).toBe(false)
|
||||
expect(browser.isConnected()).toBe(true)
|
||||
|
||||
// Verify we can still interact with the page
|
||||
const pages = browser.contexts()[0].pages()
|
||||
const discordPage = pages.find(p => p.url().includes('discord.com'))
|
||||
expect(discordPage).toBeDefined()
|
||||
|
||||
const url = await discordPage!.evaluate(() => window.location.href)
|
||||
expect(url).toContain('discord.com')
|
||||
|
||||
await browser.close()
|
||||
await page.close()
|
||||
}, 60000)
|
||||
})
|
||||
|
||||
describe('Auto-enable Tests', () => {
|
||||
let testCtx: TestContext | null = null
|
||||
|
||||
|
||||
Reference in New Issue
Block a user