changed logFilePath. add more CDP tests

This commit is contained in:
Tommy D. Rossi
2025-12-11 17:29:36 +01:00
parent 1a7cdd271e
commit 6a864cc40f
5 changed files with 387 additions and 203 deletions
+6
View File
@@ -1,5 +1,11 @@
# 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
### Patch Changes
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "playwriter",
"description": "",
"version": "0.0.16",
"version": "0.0.17",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
+374 -197
View File
@@ -8,6 +8,7 @@ import fs from 'node:fs'
import os from 'node:os'
import { getCdpUrl } from './utils.js'
import type { ExtensionState } from 'mcp-extension/src/types.js'
import type { Protocol } from 'devtools-protocol'
import { spawn } from 'node:child_process'
@@ -15,10 +16,7 @@ import { spawn } from 'node:child_process'
const execAsync = promisify(exec)
async function getExtensionServiceWorker(context: BrowserContext) {
let serviceWorkers = context.serviceWorkers().filter(sw => sw.url().startsWith('chrome-extension://'))
let serviceWorker = serviceWorkers[0]
if (!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
}
@@ -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 {
var toggleExtensionForActiveTab: () => Promise<{ isConnected: boolean; state: ExtensionState }>;
var getExtensionState: () => ExtensionState;
@@ -60,107 +151,27 @@ declare global {
describe('MCP Server Tests', () => {
let client: Awaited<ReturnType<typeof createMCPClient>>['client']
let cleanup: (() => Promise<void>) | null = null
let browserContext: Awaited<ReturnType<typeof chromium.launchPersistentContext>> | null = null
let userDataDir: string
let relayServerProcess: any
let testCtx: TestContext | null = null
beforeAll(async () => {
await killProcessOnPort(19988)
// 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)
})
testCtx = await setupTestContext({ tempDirPrefix: 'pw-test-' })
const result = await createMCPClient()
client = result.client
cleanup = result.cleanup
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
}, 600000)
afterAll(async () => {
if (browserContext) {
await browserContext.close()
}
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
}
await cleanupTestContext(testCtx, cleanup)
cleanup = null
testCtx = null
})
const getBrowserContext = () => {
if (!testCtx?.browserContext) throw new Error('Browser not initialized')
return testCtx.browserContext
}
it('should execute code and capture console output', async () => {
await client.callTool({
name: 'execute',
@@ -204,7 +215,7 @@ describe('MCP Server Tests', () => {
}, 30000)
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)
// 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 () => {
if (!browserContext) throw new Error('Browser not initialized')
// Find the correct service worker by URL
const browserContext = getBrowserContext()
const serviceWorker = await getExtensionServiceWorker(browserContext)
// 1. Create a new page
@@ -430,8 +439,7 @@ describe('MCP Server Tests', () => {
})
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)
// Connect once
@@ -502,7 +510,7 @@ describe('MCP Server Tests', () => {
await directBrowser.close()
})
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)
// 1. Setup page
@@ -551,93 +559,7 @@ describe('MCP Server Tests', () => {
})
it('should support multiple concurrent tabs', async () => {
if (!browserContext) throw new Error('Browser not initialized')
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 browserContext = getBrowserContext()
const serviceWorker = await getExtensionServiceWorker(browserContext)
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 () => {
if (!browserContext) throw new Error('Browser not initialized')
const browserContext = getBrowserContext()
const serviceWorker = await getExtensionServiceWorker(browserContext)
// 1. Open a new page (extension not yet enabled for it)
const page = await browserContext.newPage()
const targetUrl = 'https://example.com/late-enable'
await page.goto(targetUrl)
@@ -755,10 +676,9 @@ describe('MCP Server Tests', () => {
})
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)
// 1. Use the existing about:blank page from beforeAll
const pages = await browserContext.pages()
expect(pages.length).toBeGreaterThan(0)
const page = pages[0]
@@ -1233,7 +1153,7 @@ describe('MCP Server Tests', () => {
}, 30000)
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 page = await browserContext.newPage()
@@ -1260,7 +1180,7 @@ describe('MCP Server Tests', () => {
}, 30000)
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 page = await browserContext.newPage()
@@ -1288,7 +1208,7 @@ describe('MCP Server Tests', () => {
}, 30000)
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 page = await browserContext.newPage()
@@ -1328,7 +1248,7 @@ describe('MCP Server Tests', () => {
}, 30000)
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 page = await browserContext.newPage()
@@ -1358,7 +1278,7 @@ describe('MCP Server Tests', () => {
}, 60000)
it('should work with stagehand', async () => {
if (!browserContext) throw new Error('Browser not initialized')
const browserContext = getBrowserContext()
const serviceWorker = await getExtensionServiceWorker(browserContext)
await serviceWorker.evaluate(async () => {
@@ -1421,3 +1341,260 @@ function tryJsonParse(str: string) {
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 -2
View File
@@ -4,6 +4,7 @@ import { z } from 'zod'
import { Page, Browser, BrowserContext, chromium } from 'playwright-core'
import fs from 'node:fs'
import path from 'node:path'
import os from 'node:os'
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
@@ -84,6 +85,7 @@ const MAX_LOGS_PER_PAGE = 5000
const lastSnapshots: WeakMap<Page, string> = new WeakMap()
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`
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 }> {
@@ -329,7 +331,7 @@ const server = new McpServer({
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(
'execute',
+2 -3
View File
@@ -1,11 +1,10 @@
import { startPlayWriterCDPRelayServer } from './extension/cdp-relay.js'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import os from 'node:os'
import util from 'node:util'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const logFilePath = path.join(__dirname, '..', 'relay-server.log')
const logFilePath = process.env.PLAYWRITER_LOG_PATH || path.join(os.tmpdir(), 'playwriter-relay-server.log')
process.title = 'playwriter-ws-server'
fs.writeFileSync(logFilePath, '')