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()
|
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
|
### 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.
|
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 `---`
|
- Non-contiguous sections are separated by `---`
|
||||||
- Provides better context for understanding search results
|
- 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
|
## 0.0.43
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -363,6 +363,13 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
|||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app })
|
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) => {
|
app.get('/', (c) => {
|
||||||
return c.text('OK')
|
return c.text('OK')
|
||||||
})
|
})
|
||||||
@@ -375,6 +382,82 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
|||||||
return c.json({ connected: extensionWs !== null })
|
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) => {
|
app.post('/mcp-log', async (c) => {
|
||||||
try {
|
try {
|
||||||
const { level, args } = await c.req.json()
|
const { level, args } = await c.req.json()
|
||||||
|
|||||||
@@ -2527,6 +2527,65 @@ describe('MCP Server Tests', () => {
|
|||||||
await page.close()
|
await page.close()
|
||||||
}, 60000)
|
}, 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)
|
}, 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', () => {
|
describe('Auto-enable Tests', () => {
|
||||||
let testCtx: TestContext | null = null
|
let testCtx: TestContext | null = null
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user