diff --git a/playwriter/CHANGELOG.md b/playwriter/CHANGELOG.md index 436aae0..949168f 100644 --- a/playwriter/CHANGELOG.md +++ b/playwriter/CHANGELOG.md @@ -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 diff --git a/playwriter/package.json b/playwriter/package.json index 868a640..4812f1e 100644 --- a/playwriter/package.json +++ b/playwriter/package.json @@ -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", diff --git a/playwriter/src/mcp.test.ts b/playwriter/src/mcp.test.ts index 8cad08d..a98f410 100644 --- a/playwriter/src/mcp.test.ts +++ b/playwriter/src/mcp.test.ts @@ -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 { } } +interface TestContext { + browserContext: Awaited> + userDataDir: string + relayServerProcess: ReturnType +} + +async function setupTestContext({ tempDirPrefix }: { tempDirPrefix: string }): Promise { + 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((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) | null): Promise { + 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>['client'] let cleanup: (() => Promise) | null = null - let browserContext: Awaited> | 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((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, - "idB": Any, - } - `) - 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((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((_, 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 = {} + + 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) +}) diff --git a/playwriter/src/mcp.ts b/playwriter/src/mcp.ts index 4a9a11f..9bc15b7 100644 --- a/playwriter/src/mcp.ts +++ b/playwriter/src/mcp.ts @@ -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 = 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 { } } - 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', diff --git a/playwriter/src/start-relay-server.ts b/playwriter/src/start-relay-server.ts index b3772d8..ecd3282 100644 --- a/playwriter/src/start-relay-server.ts +++ b/playwriter/src/start-relay-server.ts @@ -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, '')