changed logFilePath. add more CDP tests
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.0.17
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- **Improved error debugging**: Log file path now included in error messages and tool description. Log file writes to OS temp directory by default (`PLAYWRITER_LOG_PATH` env var to override)
|
||||||
|
|
||||||
## 0.0.16
|
## 0.0.16
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "playwriter",
|
"name": "playwriter",
|
||||||
"description": "",
|
"description": "",
|
||||||
"version": "0.0.16",
|
"version": "0.0.17",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|||||||
+374
-197
@@ -8,6 +8,7 @@ import fs from 'node:fs'
|
|||||||
import os from 'node:os'
|
import os from 'node:os'
|
||||||
import { getCdpUrl } from './utils.js'
|
import { getCdpUrl } from './utils.js'
|
||||||
import type { ExtensionState } from 'mcp-extension/src/types.js'
|
import type { ExtensionState } from 'mcp-extension/src/types.js'
|
||||||
|
import type { Protocol } from 'devtools-protocol'
|
||||||
|
|
||||||
import { spawn } from 'node:child_process'
|
import { spawn } from 'node:child_process'
|
||||||
|
|
||||||
@@ -15,10 +16,7 @@ import { spawn } from 'node:child_process'
|
|||||||
const execAsync = promisify(exec)
|
const execAsync = promisify(exec)
|
||||||
|
|
||||||
async function getExtensionServiceWorker(context: BrowserContext) {
|
async function getExtensionServiceWorker(context: BrowserContext) {
|
||||||
|
|
||||||
let serviceWorkers = context.serviceWorkers().filter(sw => sw.url().startsWith('chrome-extension://'))
|
let serviceWorkers = context.serviceWorkers().filter(sw => sw.url().startsWith('chrome-extension://'))
|
||||||
|
|
||||||
|
|
||||||
let serviceWorker = serviceWorkers[0]
|
let serviceWorker = serviceWorkers[0]
|
||||||
if (!serviceWorker) {
|
if (!serviceWorker) {
|
||||||
serviceWorker = await context.waitForEvent('serviceworker', {
|
serviceWorker = await context.waitForEvent('serviceworker', {
|
||||||
@@ -26,6 +24,14 @@ async function getExtensionServiceWorker(context: BrowserContext) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
const isReady = await serviceWorker.evaluate(() => {
|
||||||
|
// @ts-ignore
|
||||||
|
return typeof globalThis.toggleExtensionForActiveTab === 'function'
|
||||||
|
})
|
||||||
|
if (isReady) break
|
||||||
|
await new Promise(r => setTimeout(r, 100))
|
||||||
|
}
|
||||||
|
|
||||||
return serviceWorker
|
return serviceWorker
|
||||||
}
|
}
|
||||||
@@ -51,6 +57,91 @@ async function killProcessOnPort(port: number): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TestContext {
|
||||||
|
browserContext: Awaited<ReturnType<typeof chromium.launchPersistentContext>>
|
||||||
|
userDataDir: string
|
||||||
|
relayServerProcess: ReturnType<typeof spawn>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupTestContext({ tempDirPrefix }: { tempDirPrefix: string }): Promise<TestContext> {
|
||||||
|
await killProcessOnPort(19988)
|
||||||
|
|
||||||
|
console.log('Building extension...')
|
||||||
|
await execAsync('TESTING=1 pnpm build', { cwd: '../extension' })
|
||||||
|
console.log('Extension built')
|
||||||
|
|
||||||
|
const localLogPath = path.join(process.cwd(), 'relay-server.log')
|
||||||
|
const relayServerProcess = spawn('pnpm', ['tsx', 'src/start-relay-server.ts'], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: { ...process.env, PLAYWRITER_LOG_PATH: localLogPath }
|
||||||
|
})
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let retries = 0
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync('lsof -ti:19988')
|
||||||
|
if (stdout.trim()) {
|
||||||
|
clearInterval(interval)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
retries++
|
||||||
|
if (retries > 30) {
|
||||||
|
clearInterval(interval)
|
||||||
|
reject(new Error('Relay server failed to start'))
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), tempDirPrefix))
|
||||||
|
const extensionPath = path.resolve('../extension/dist')
|
||||||
|
|
||||||
|
const browserContext = await chromium.launchPersistentContext(userDataDir, {
|
||||||
|
channel: 'chromium',
|
||||||
|
headless: !process.env.HEADFUL,
|
||||||
|
args: [
|
||||||
|
`--disable-extensions-except=${extensionPath}`,
|
||||||
|
`--load-extension=${extensionPath}`,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||||
|
|
||||||
|
const page = await browserContext.newPage()
|
||||||
|
await page.goto('about:blank')
|
||||||
|
|
||||||
|
await serviceWorker.evaluate(async () => {
|
||||||
|
await globalThis.toggleExtensionForActiveTab()
|
||||||
|
})
|
||||||
|
|
||||||
|
return { browserContext, userDataDir, relayServerProcess }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupTestContext(ctx: TestContext | null, cleanup?: (() => Promise<void>) | null): Promise<void> {
|
||||||
|
if (ctx?.browserContext) {
|
||||||
|
await ctx.browserContext.close()
|
||||||
|
}
|
||||||
|
if (ctx?.relayServerProcess) {
|
||||||
|
ctx.relayServerProcess.kill()
|
||||||
|
}
|
||||||
|
await killProcessOnPort(19988)
|
||||||
|
|
||||||
|
if (ctx?.userDataDir) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(ctx.userDataDir, { recursive: true, force: true })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to cleanup user data dir:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cleanup) {
|
||||||
|
await cleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
var toggleExtensionForActiveTab: () => Promise<{ isConnected: boolean; state: ExtensionState }>;
|
var toggleExtensionForActiveTab: () => Promise<{ isConnected: boolean; state: ExtensionState }>;
|
||||||
var getExtensionState: () => ExtensionState;
|
var getExtensionState: () => ExtensionState;
|
||||||
@@ -60,107 +151,27 @@ declare global {
|
|||||||
describe('MCP Server Tests', () => {
|
describe('MCP Server Tests', () => {
|
||||||
let client: Awaited<ReturnType<typeof createMCPClient>>['client']
|
let client: Awaited<ReturnType<typeof createMCPClient>>['client']
|
||||||
let cleanup: (() => Promise<void>) | null = null
|
let cleanup: (() => Promise<void>) | null = null
|
||||||
let browserContext: Awaited<ReturnType<typeof chromium.launchPersistentContext>> | null = null
|
let testCtx: TestContext | null = null
|
||||||
let userDataDir: string
|
|
||||||
let relayServerProcess: any
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await killProcessOnPort(19988)
|
testCtx = await setupTestContext({ tempDirPrefix: 'pw-test-' })
|
||||||
|
|
||||||
// Build extension
|
|
||||||
console.log('Building extension...')
|
|
||||||
await execAsync('TESTING=1 pnpm build', { cwd: '../extension' })
|
|
||||||
console.log('Extension built')
|
|
||||||
|
|
||||||
// Start Relay Server manually
|
|
||||||
relayServerProcess = spawn('pnpm', ['tsx', 'src/start-relay-server.ts'], {
|
|
||||||
cwd: process.cwd(),
|
|
||||||
stdio: 'inherit'
|
|
||||||
})
|
|
||||||
|
|
||||||
// Wait for port 19988 to be ready
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
let retries = 0
|
|
||||||
const interval = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const { stdout } = await execAsync('lsof -ti:19988')
|
|
||||||
if (stdout.trim()) {
|
|
||||||
clearInterval(interval)
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
retries++
|
|
||||||
if (retries > 30) {
|
|
||||||
clearInterval(interval)
|
|
||||||
reject(new Error('Relay server failed to start'))
|
|
||||||
}
|
|
||||||
}, 1000)
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await createMCPClient()
|
const result = await createMCPClient()
|
||||||
client = result.client
|
client = result.client
|
||||||
cleanup = result.cleanup
|
cleanup = result.cleanup
|
||||||
|
}, 600000)
|
||||||
userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pw-test-'))
|
|
||||||
const extensionPath = path.resolve('../extension/dist')
|
|
||||||
|
|
||||||
browserContext = await chromium.launchPersistentContext(userDataDir, {
|
|
||||||
channel: 'chromium', // <- this opts into new headless
|
|
||||||
headless: !process.env.HEADFUL,
|
|
||||||
args: [
|
|
||||||
`--disable-extensions-except=${extensionPath}`,
|
|
||||||
`--load-extension=${extensionPath}`,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
// Wait for service worker and connect
|
|
||||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
|
||||||
|
|
||||||
// Wait for extension to initialize global functions
|
|
||||||
for (let i = 0; i < 50; i++) {
|
|
||||||
const isReady = await serviceWorker.evaluate(() => {
|
|
||||||
// @ts-ignore
|
|
||||||
return typeof globalThis.toggleExtensionForActiveTab === 'function'
|
|
||||||
})
|
|
||||||
if (isReady) break
|
|
||||||
await new Promise(r => setTimeout(r, 100))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a page to attach to
|
|
||||||
const page = await browserContext.newPage()
|
|
||||||
await page.goto('about:blank')
|
|
||||||
|
|
||||||
// Connect the tab
|
|
||||||
await serviceWorker.evaluate(async () => {
|
|
||||||
await globalThis.toggleExtensionForActiveTab()
|
|
||||||
})
|
|
||||||
|
|
||||||
}, 600000) // 10 minutes timeout
|
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
if (browserContext) {
|
await cleanupTestContext(testCtx, cleanup)
|
||||||
await browserContext.close()
|
cleanup = null
|
||||||
}
|
testCtx = null
|
||||||
if (relayServerProcess) {
|
|
||||||
relayServerProcess.kill()
|
|
||||||
}
|
|
||||||
await killProcessOnPort(19988)
|
|
||||||
|
|
||||||
if (userDataDir) {
|
|
||||||
try {
|
|
||||||
fs.rmSync(userDataDir, { recursive: true, force: true })
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to cleanup user data dir:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (cleanup) {
|
|
||||||
await cleanup()
|
|
||||||
cleanup = null
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const getBrowserContext = () => {
|
||||||
|
if (!testCtx?.browserContext) throw new Error('Browser not initialized')
|
||||||
|
return testCtx.browserContext
|
||||||
|
}
|
||||||
|
|
||||||
it('should execute code and capture console output', async () => {
|
it('should execute code and capture console output', async () => {
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
name: 'execute',
|
name: 'execute',
|
||||||
@@ -204,7 +215,7 @@ describe('MCP Server Tests', () => {
|
|||||||
}, 30000)
|
}, 30000)
|
||||||
|
|
||||||
it('should show extension as connected for pages created via newPage()', async () => {
|
it('should show extension as connected for pages created via newPage()', async () => {
|
||||||
if (!browserContext) throw new Error('Browser not initialized')
|
const browserContext = getBrowserContext()
|
||||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||||
|
|
||||||
// Create a page via MCP (which uses context.newPage())
|
// Create a page via MCP (which uses context.newPage())
|
||||||
@@ -341,9 +352,7 @@ describe('MCP Server Tests', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should handle new pages and toggling with new connections', async () => {
|
it('should handle new pages and toggling with new connections', async () => {
|
||||||
if (!browserContext) throw new Error('Browser not initialized')
|
const browserContext = getBrowserContext()
|
||||||
|
|
||||||
// Find the correct service worker by URL
|
|
||||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||||
|
|
||||||
// 1. Create a new page
|
// 1. Create a new page
|
||||||
@@ -430,8 +439,7 @@ describe('MCP Server Tests', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should handle new pages and toggling with persistent connection', async () => {
|
it('should handle new pages and toggling with persistent connection', async () => {
|
||||||
if (!browserContext) throw new Error('Browser not initialized')
|
const browserContext = getBrowserContext()
|
||||||
|
|
||||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||||
|
|
||||||
// Connect once
|
// Connect once
|
||||||
@@ -502,7 +510,7 @@ describe('MCP Server Tests', () => {
|
|||||||
await directBrowser.close()
|
await directBrowser.close()
|
||||||
})
|
})
|
||||||
it('should maintain connection across reloads and navigation', async () => {
|
it('should maintain connection across reloads and navigation', async () => {
|
||||||
if (!browserContext) throw new Error('Browser not initialized')
|
const browserContext = getBrowserContext()
|
||||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||||
|
|
||||||
// 1. Setup page
|
// 1. Setup page
|
||||||
@@ -551,93 +559,7 @@ describe('MCP Server Tests', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should support multiple concurrent tabs', async () => {
|
it('should support multiple concurrent tabs', async () => {
|
||||||
if (!browserContext) throw new Error('Browser not initialized')
|
const browserContext = getBrowserContext()
|
||||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500))
|
|
||||||
|
|
||||||
// Tab A
|
|
||||||
const pageA = await browserContext.newPage()
|
|
||||||
await pageA.goto('https://example.com/tab-a')
|
|
||||||
await pageA.bringToFront()
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500))
|
|
||||||
await serviceWorker.evaluate(async () => {
|
|
||||||
await globalThis.toggleExtensionForActiveTab()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Tab B
|
|
||||||
const pageB = await browserContext.newPage()
|
|
||||||
await pageB.goto('https://example.com/tab-b')
|
|
||||||
await pageB.bringToFront()
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500))
|
|
||||||
await serviceWorker.evaluate(async () => {
|
|
||||||
await globalThis.toggleExtensionForActiveTab()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Get target IDs for both
|
|
||||||
const targetIds = await serviceWorker.evaluate(async () => {
|
|
||||||
const state = globalThis.getExtensionState()
|
|
||||||
const chrome = globalThis.chrome
|
|
||||||
const tabs = await chrome.tabs.query({})
|
|
||||||
const tabA = tabs.find((t: any) => t.url?.includes('tab-a'))
|
|
||||||
const tabB = tabs.find((t: any) => t.url?.includes('tab-b'))
|
|
||||||
return {
|
|
||||||
idA: state.tabs.get(tabA?.id ?? -1)?.targetId,
|
|
||||||
idB: state.tabs.get(tabB?.id ?? -1)?.targetId
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(targetIds).toMatchInlineSnapshot({
|
|
||||||
idA: expect.any(String),
|
|
||||||
idB: expect.any(String)
|
|
||||||
}, `
|
|
||||||
{
|
|
||||||
"idA": Any<String>,
|
|
||||||
"idB": Any<String>,
|
|
||||||
}
|
|
||||||
`)
|
|
||||||
expect(targetIds.idA).not.toBe(targetIds.idB)
|
|
||||||
|
|
||||||
// Verify independent connections
|
|
||||||
const browser = await chromium.connectOverCDP(getCdpUrl())
|
|
||||||
|
|
||||||
const pages = browser.contexts()[0].pages()
|
|
||||||
|
|
||||||
const results = await Promise.all(pages.map(async (p) => ({
|
|
||||||
url: p.url(),
|
|
||||||
title: await p.title()
|
|
||||||
})))
|
|
||||||
|
|
||||||
expect(results).toMatchInlineSnapshot(`
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"title": "",
|
|
||||||
"url": "about:blank",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Example Domain",
|
|
||||||
"url": "https://example.com/tab-a",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Example Domain",
|
|
||||||
"url": "https://example.com/tab-b",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
`)
|
|
||||||
|
|
||||||
// Verify execution on both pages
|
|
||||||
const pageA_CDP = pages.find(p => p.url().includes('tab-a'))
|
|
||||||
const pageB_CDP = pages.find(p => p.url().includes('tab-b'))
|
|
||||||
|
|
||||||
expect(await pageA_CDP?.evaluate(() => 10 + 10)).toBe(20)
|
|
||||||
expect(await pageB_CDP?.evaluate(() => 20 + 20)).toBe(40)
|
|
||||||
|
|
||||||
await browser.close()
|
|
||||||
await pageA.close()
|
|
||||||
await pageB.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should support multiple concurrent tabs', async () => {
|
|
||||||
if (!browserContext) throw new Error('Browser not initialized')
|
|
||||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||||
await new Promise(resolve => setTimeout(resolve, 500))
|
await new Promise(resolve => setTimeout(resolve, 500))
|
||||||
|
|
||||||
@@ -723,10 +645,9 @@ describe('MCP Server Tests', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should show correct url when enabling extension after navigation', async () => {
|
it('should show correct url when enabling extension after navigation', async () => {
|
||||||
if (!browserContext) throw new Error('Browser not initialized')
|
const browserContext = getBrowserContext()
|
||||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||||
|
|
||||||
// 1. Open a new page (extension not yet enabled for it)
|
|
||||||
const page = await browserContext.newPage()
|
const page = await browserContext.newPage()
|
||||||
const targetUrl = 'https://example.com/late-enable'
|
const targetUrl = 'https://example.com/late-enable'
|
||||||
await page.goto(targetUrl)
|
await page.goto(targetUrl)
|
||||||
@@ -755,10 +676,9 @@ describe('MCP Server Tests', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should be able to reconnect after disconnecting everything', async () => {
|
it('should be able to reconnect after disconnecting everything', async () => {
|
||||||
if (!browserContext) throw new Error('Browser not initialized')
|
const browserContext = getBrowserContext()
|
||||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||||
|
|
||||||
// 1. Use the existing about:blank page from beforeAll
|
|
||||||
const pages = await browserContext.pages()
|
const pages = await browserContext.pages()
|
||||||
expect(pages.length).toBeGreaterThan(0)
|
expect(pages.length).toBeGreaterThan(0)
|
||||||
const page = pages[0]
|
const page = pages[0]
|
||||||
@@ -1233,7 +1153,7 @@ describe('MCP Server Tests', () => {
|
|||||||
}, 30000)
|
}, 30000)
|
||||||
|
|
||||||
it('should maintain correct page.url() with service worker pages', async () => {
|
it('should maintain correct page.url() with service worker pages', async () => {
|
||||||
if (!browserContext) throw new Error('Browser not initialized')
|
const browserContext = getBrowserContext()
|
||||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||||
|
|
||||||
const page = await browserContext.newPage()
|
const page = await browserContext.newPage()
|
||||||
@@ -1260,7 +1180,7 @@ describe('MCP Server Tests', () => {
|
|||||||
}, 30000)
|
}, 30000)
|
||||||
|
|
||||||
it('should maintain correct page.url() after repeated connections', async () => {
|
it('should maintain correct page.url() after repeated connections', async () => {
|
||||||
if (!browserContext) throw new Error('Browser not initialized')
|
const browserContext = getBrowserContext()
|
||||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||||
|
|
||||||
const page = await browserContext.newPage()
|
const page = await browserContext.newPage()
|
||||||
@@ -1288,7 +1208,7 @@ describe('MCP Server Tests', () => {
|
|||||||
}, 30000)
|
}, 30000)
|
||||||
|
|
||||||
it('should maintain correct page.url() with concurrent MCP and CDP connections', async () => {
|
it('should maintain correct page.url() with concurrent MCP and CDP connections', async () => {
|
||||||
if (!browserContext) throw new Error('Browser not initialized')
|
const browserContext = getBrowserContext()
|
||||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||||
|
|
||||||
const page = await browserContext.newPage()
|
const page = await browserContext.newPage()
|
||||||
@@ -1328,7 +1248,7 @@ describe('MCP Server Tests', () => {
|
|||||||
}, 30000)
|
}, 30000)
|
||||||
|
|
||||||
it('should maintain correct page.url() with iframe-heavy pages', async () => {
|
it('should maintain correct page.url() with iframe-heavy pages', async () => {
|
||||||
if (!browserContext) throw new Error('Browser not initialized')
|
const browserContext = getBrowserContext()
|
||||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||||
|
|
||||||
const page = await browserContext.newPage()
|
const page = await browserContext.newPage()
|
||||||
@@ -1358,7 +1278,7 @@ describe('MCP Server Tests', () => {
|
|||||||
}, 60000)
|
}, 60000)
|
||||||
|
|
||||||
it('should work with stagehand', async () => {
|
it('should work with stagehand', async () => {
|
||||||
if (!browserContext) throw new Error('Browser not initialized')
|
const browserContext = getBrowserContext()
|
||||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||||
|
|
||||||
await serviceWorker.evaluate(async () => {
|
await serviceWorker.evaluate(async () => {
|
||||||
@@ -1421,3 +1341,260 @@ function tryJsonParse(str: string) {
|
|||||||
return str
|
return str
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe('CDP Session Tests', () => {
|
||||||
|
let testCtx: TestContext | null = null
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
testCtx = await setupTestContext({ tempDirPrefix: 'pw-cdp-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 enable debugger and pause on debugger statement via CDP session', async () => {
|
||||||
|
const browserContext = getBrowserContext()
|
||||||
|
const page = await browserContext.newPage()
|
||||||
|
await page.goto('https://example.com/')
|
||||||
|
|
||||||
|
const cdpSession = await page.context().newCDPSession(page)
|
||||||
|
await cdpSession.send('Debugger.enable')
|
||||||
|
|
||||||
|
const pausedPromise = new Promise<Protocol.Debugger.PausedEvent>((resolve) => {
|
||||||
|
cdpSession.once('Debugger.paused', (params) => {
|
||||||
|
resolve(params as Protocol.Debugger.PausedEvent)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
page.evaluate(`
|
||||||
|
(function testFunction() {
|
||||||
|
const localVar = 'hello';
|
||||||
|
const numberVar = 42;
|
||||||
|
const objVar = { key: 'value', nested: { a: 1 } };
|
||||||
|
debugger;
|
||||||
|
return localVar + numberVar;
|
||||||
|
})()
|
||||||
|
`)
|
||||||
|
|
||||||
|
const pausedEvent = await Promise.race([
|
||||||
|
pausedPromise,
|
||||||
|
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('Debugger.paused timeout')), 5000))
|
||||||
|
])
|
||||||
|
|
||||||
|
const stackTrace = pausedEvent.callFrames.map(frame => ({
|
||||||
|
functionName: frame.functionName || '(anonymous)',
|
||||||
|
lineNumber: frame.location.lineNumber,
|
||||||
|
columnNumber: frame.location.columnNumber,
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect({
|
||||||
|
reason: pausedEvent.reason,
|
||||||
|
stackTrace: stackTrace.slice(0, 3),
|
||||||
|
}).toMatchInlineSnapshot(`
|
||||||
|
{
|
||||||
|
"reason": "other",
|
||||||
|
"stackTrace": [
|
||||||
|
{
|
||||||
|
"columnNumber": 16,
|
||||||
|
"functionName": "testFunction",
|
||||||
|
"lineNumber": 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columnNumber": 14,
|
||||||
|
"functionName": "(anonymous)",
|
||||||
|
"lineNumber": 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columnNumber": 29,
|
||||||
|
"functionName": "evaluate",
|
||||||
|
"lineNumber": 289,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
const topFrame = pausedEvent.callFrames[0]
|
||||||
|
const scopeChain = topFrame.scopeChain
|
||||||
|
|
||||||
|
const localScope = scopeChain.find(s => s.type === 'local')
|
||||||
|
const localVars: Record<string, unknown> = {}
|
||||||
|
|
||||||
|
if (localScope?.object.objectId) {
|
||||||
|
const { result } = await cdpSession.send('Runtime.getProperties', {
|
||||||
|
objectId: localScope.object.objectId,
|
||||||
|
ownProperties: true,
|
||||||
|
}) as { result: Protocol.Runtime.PropertyDescriptor[] }
|
||||||
|
|
||||||
|
for (const prop of result) {
|
||||||
|
if (prop.value) {
|
||||||
|
localVars[prop.name] = prop.value.type === 'object'
|
||||||
|
? `[object ${prop.value.className || prop.value.subtype || 'Object'}]`
|
||||||
|
: prop.value.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect({
|
||||||
|
scopeTypes: scopeChain.map(s => s.type),
|
||||||
|
localVariables: localVars,
|
||||||
|
}).toMatchInlineSnapshot(`
|
||||||
|
{
|
||||||
|
"localVariables": {
|
||||||
|
"localVar": "hello",
|
||||||
|
"numberVar": 42,
|
||||||
|
"objVar": "[object Object]",
|
||||||
|
},
|
||||||
|
"scopeTypes": [
|
||||||
|
"local",
|
||||||
|
"global",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
const evalResult = await cdpSession.send('Debugger.evaluateOnCallFrame', {
|
||||||
|
callFrameId: topFrame.callFrameId,
|
||||||
|
expression: 'localVar + " world " + numberVar',
|
||||||
|
}) as { result: Protocol.Runtime.RemoteObject }
|
||||||
|
|
||||||
|
expect({
|
||||||
|
evaluatedExpression: 'localVar + " world " + numberVar',
|
||||||
|
result: evalResult.result.value,
|
||||||
|
type: evalResult.result.type,
|
||||||
|
}).toMatchInlineSnapshot(`
|
||||||
|
{
|
||||||
|
"evaluatedExpression": "localVar + " world " + numberVar",
|
||||||
|
"result": "hello world 42",
|
||||||
|
"type": "string",
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
await cdpSession.send('Debugger.resume')
|
||||||
|
await cdpSession.send('Debugger.disable')
|
||||||
|
await cdpSession.detach()
|
||||||
|
await page.close()
|
||||||
|
}, 30000)
|
||||||
|
|
||||||
|
it('should profile JavaScript execution using CDP Profiler', async () => {
|
||||||
|
const browserContext = getBrowserContext()
|
||||||
|
const page = await browserContext.newPage()
|
||||||
|
await page.goto('https://example.com/')
|
||||||
|
|
||||||
|
const cdpSession = await page.context().newCDPSession(page)
|
||||||
|
await cdpSession.send('Profiler.enable')
|
||||||
|
await cdpSession.send('Profiler.start')
|
||||||
|
|
||||||
|
await page.evaluate(`
|
||||||
|
(() => {
|
||||||
|
function fibonacci(n) {
|
||||||
|
if (n <= 1) return n
|
||||||
|
return fibonacci(n - 1) + fibonacci(n - 2)
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
fibonacci(20)
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
document.querySelectorAll('*')
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
`)
|
||||||
|
|
||||||
|
const { profile } = await cdpSession.send('Profiler.stop') as { profile: Protocol.Profiler.Profile }
|
||||||
|
|
||||||
|
const functionNames = profile.nodes
|
||||||
|
.map(n => n.callFrame.functionName)
|
||||||
|
.filter(name => name && name.length > 0)
|
||||||
|
.slice(0, 10)
|
||||||
|
|
||||||
|
expect({
|
||||||
|
hasNodes: profile.nodes.length > 0,
|
||||||
|
nodeCount: profile.nodes.length,
|
||||||
|
durationMicroseconds: profile.endTime - profile.startTime,
|
||||||
|
sampleFunctionNames: functionNames,
|
||||||
|
}).toMatchInlineSnapshot(`
|
||||||
|
{
|
||||||
|
"durationMicroseconds": 7595,
|
||||||
|
"hasNodes": true,
|
||||||
|
"nodeCount": 5,
|
||||||
|
"sampleFunctionNames": [
|
||||||
|
"(root)",
|
||||||
|
"(program)",
|
||||||
|
"(idle)",
|
||||||
|
"evaluate",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
await cdpSession.send('Profiler.disable')
|
||||||
|
await cdpSession.detach()
|
||||||
|
await page.close()
|
||||||
|
}, 30000)
|
||||||
|
|
||||||
|
it('should capture performance metrics via CDP Performance domain', async () => {
|
||||||
|
const browserContext = getBrowserContext()
|
||||||
|
const page = await browserContext.newPage()
|
||||||
|
await page.goto('https://example.com/')
|
||||||
|
|
||||||
|
const cdpSession = await page.context().newCDPSession(page)
|
||||||
|
await cdpSession.send('Performance.enable')
|
||||||
|
|
||||||
|
const { metrics } = await cdpSession.send('Performance.getMetrics') as { metrics: Protocol.Performance.Metric[] }
|
||||||
|
|
||||||
|
const selectedMetrics = metrics
|
||||||
|
.filter(m => ['Documents', 'Nodes', 'JSEventListeners', 'LayoutCount', 'ScriptDuration', 'JSHeapUsedSize'].includes(m.name))
|
||||||
|
.map(m => ({
|
||||||
|
name: m.name,
|
||||||
|
hasValue: typeof m.value === 'number',
|
||||||
|
valueType: typeof m.value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(selectedMetrics).toMatchInlineSnapshot(`
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"hasValue": true,
|
||||||
|
"name": "Documents",
|
||||||
|
"valueType": "number",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hasValue": true,
|
||||||
|
"name": "JSEventListeners",
|
||||||
|
"valueType": "number",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hasValue": true,
|
||||||
|
"name": "Nodes",
|
||||||
|
"valueType": "number",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hasValue": true,
|
||||||
|
"name": "LayoutCount",
|
||||||
|
"valueType": "number",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hasValue": true,
|
||||||
|
"name": "ScriptDuration",
|
||||||
|
"valueType": "number",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hasValue": true,
|
||||||
|
"name": "JSHeapUsedSize",
|
||||||
|
"valueType": "number",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`)
|
||||||
|
|
||||||
|
const metricNames = metrics.map(m => m.name)
|
||||||
|
expect(metricNames).toContain('JSHeapUsedSize')
|
||||||
|
expect(metricNames).toContain('JSHeapTotalSize')
|
||||||
|
|
||||||
|
await cdpSession.send('Performance.disable')
|
||||||
|
await cdpSession.detach()
|
||||||
|
await page.close()
|
||||||
|
}, 30000)
|
||||||
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { z } from 'zod'
|
|||||||
import { Page, Browser, BrowserContext, chromium } from 'playwright-core'
|
import { Page, Browser, BrowserContext, chromium } from 'playwright-core'
|
||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
import os from 'node:os'
|
||||||
import { spawn } from 'node:child_process'
|
import { spawn } from 'node:child_process'
|
||||||
import { createRequire } from 'node:module'
|
import { createRequire } from 'node:module'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
@@ -84,6 +85,7 @@ const MAX_LOGS_PER_PAGE = 5000
|
|||||||
const lastSnapshots: WeakMap<Page, string> = new WeakMap()
|
const lastSnapshots: WeakMap<Page, string> = new WeakMap()
|
||||||
|
|
||||||
const RELAY_PORT = 19988
|
const RELAY_PORT = 19988
|
||||||
|
const LOG_FILE_PATH = process.env.PLAYWRITER_LOG_PATH || path.join(os.tmpdir(), 'playwriter-relay-server.log')
|
||||||
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 isRegExp(value: any): value is RegExp {
|
function isRegExp(value: any): value is RegExp {
|
||||||
@@ -152,7 +154,7 @@ async function ensureRelayServer(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error('Failed to start CDP relay server after 5 seconds')
|
throw new Error(`Failed to start CDP relay server after 5 seconds. Check logs at: ${LOG_FILE_PATH}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureConnection(): Promise<{ browser: Browser; page: Page }> {
|
async function ensureConnection(): Promise<{ browser: Browser; page: Page }> {
|
||||||
@@ -329,7 +331,7 @@ const server = new McpServer({
|
|||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
})
|
})
|
||||||
|
|
||||||
const promptContent = fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), 'prompt.md'), 'utf-8')
|
const promptContent = fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), 'prompt.md'), 'utf-8') + `\n\nfor debugging errors, check relay server logs at: ${LOG_FILE_PATH}`
|
||||||
|
|
||||||
server.tool(
|
server.tool(
|
||||||
'execute',
|
'execute',
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { startPlayWriterCDPRelayServer } from './extension/cdp-relay.js'
|
import { startPlayWriterCDPRelayServer } from './extension/cdp-relay.js'
|
||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import os from 'node:os'
|
||||||
import util from 'node:util'
|
import util from 'node:util'
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const logFilePath = process.env.PLAYWRITER_LOG_PATH || path.join(os.tmpdir(), 'playwriter-relay-server.log')
|
||||||
const logFilePath = path.join(__dirname, '..', 'relay-server.log')
|
|
||||||
|
|
||||||
process.title = 'playwriter-ws-server'
|
process.title = 'playwriter-ws-server'
|
||||||
fs.writeFileSync(logFilePath, '')
|
fs.writeFileSync(logFilePath, '')
|
||||||
|
|||||||
Reference in New Issue
Block a user