less root crashes in mcp. more error logs.
This commit is contained in:
@@ -732,7 +732,7 @@ const icons = {
|
||||
},
|
||||
title: 'Error',
|
||||
badgeText: '!',
|
||||
badgeColor: [0, 0, 0, 0] as [number, number, number, number],
|
||||
badgeColor: [220, 38, 38, 255] as [number, number, number, number],
|
||||
},
|
||||
} as const
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"@mizchi/selector-generator": "1.50.0-next",
|
||||
"@types/chrome": "^0.0.315",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/ui": "^4.0.8",
|
||||
"image-size": "^2.0.2",
|
||||
"mcp-extension": "workspace:*",
|
||||
|
||||
@@ -18,29 +18,33 @@ export class CDPSession {
|
||||
constructor(ws: WebSocket) {
|
||||
this.ws = ws
|
||||
this.ws.on('message', (data) => {
|
||||
const message = JSON.parse(data.toString()) as CDPResponseBase | CDPEventBase
|
||||
try {
|
||||
const message = JSON.parse(data.toString()) as CDPResponseBase | CDPEventBase
|
||||
|
||||
if ('id' in message) {
|
||||
const response = message as CDPResponseBase
|
||||
const pending = this.pendingRequests.get(response.id)
|
||||
if (pending) {
|
||||
this.pendingRequests.delete(response.id)
|
||||
if (response.error) {
|
||||
pending.reject(new Error(response.error.message))
|
||||
} else {
|
||||
pending.resolve(response.result)
|
||||
if ('id' in message) {
|
||||
const response = message as CDPResponseBase
|
||||
const pending = this.pendingRequests.get(response.id)
|
||||
if (pending) {
|
||||
this.pendingRequests.delete(response.id)
|
||||
if (response.error) {
|
||||
pending.reject(new Error(response.error.message))
|
||||
} else {
|
||||
pending.resolve(response.result)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if ('method' in message) {
|
||||
const event = message as CDPEventBase
|
||||
if (event.sessionId === this.sessionId || !event.sessionId) {
|
||||
const listeners = this.eventListeners.get(event.method)
|
||||
if (listeners) {
|
||||
for (const listener of listeners) {
|
||||
listener(event.params)
|
||||
} else if ('method' in message) {
|
||||
const event = message as CDPEventBase
|
||||
if (event.sessionId === this.sessionId || !event.sessionId) {
|
||||
const listeners = this.eventListeners.get(event.method)
|
||||
if (listeners) {
|
||||
for (const listener of listeners) {
|
||||
listener(event.params)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[CDPSession] Message handling error:', e)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -51,7 +55,7 @@ export class CDPSession {
|
||||
|
||||
send<K extends keyof ProtocolMapping.Commands>(
|
||||
method: K,
|
||||
params?: ProtocolMapping.Commands[K]['paramsType'][0]
|
||||
params?: ProtocolMapping.Commands[K]['paramsType'][0],
|
||||
): Promise<ProtocolMapping.Commands[K]['returnType']> {
|
||||
const id = ++this.messageId
|
||||
const message: Record<string, unknown> = { id, method, params }
|
||||
@@ -73,10 +77,16 @@ export class CDPSession {
|
||||
reject: (error) => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
this.ws.send(JSON.stringify(message))
|
||||
try {
|
||||
this.ws.send(JSON.stringify(message))
|
||||
} catch (error) {
|
||||
clearTimeout(timeout)
|
||||
this.pendingRequests.delete(id)
|
||||
reject(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -92,12 +102,16 @@ export class CDPSession {
|
||||
}
|
||||
|
||||
detach() {
|
||||
for (const pending of this.pendingRequests.values()) {
|
||||
pending.reject(new Error('CDPSession detached'))
|
||||
try {
|
||||
for (const pending of this.pendingRequests.values()) {
|
||||
pending.reject(new Error('CDPSession detached'))
|
||||
}
|
||||
this.pendingRequests.clear()
|
||||
this.eventListeners.clear()
|
||||
this.ws.close()
|
||||
} catch (e) {
|
||||
console.error('[CDPSession] WebSocket close error:', e)
|
||||
}
|
||||
this.pendingRequests.clear()
|
||||
this.eventListeners.clear()
|
||||
this.ws.close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +133,7 @@ export async function getCDPSessionForPage({ page, wsUrl }: { page: Page; wsUrl:
|
||||
}
|
||||
|
||||
const { targetInfos } = await cdp.send('Target.getTargets')
|
||||
const pageTargets = targetInfos.filter(t => t.type === 'page')
|
||||
const pageTargets = targetInfos.filter((t) => t.type === 'page')
|
||||
|
||||
if (pageIndex >= pageTargets.length) {
|
||||
cdp.detach()
|
||||
@@ -134,7 +148,7 @@ export async function getCDPSessionForPage({ page, wsUrl }: { page: Page; wsUrl:
|
||||
|
||||
const { sessionId } = await cdp.send('Target.attachToTarget', {
|
||||
targetId: target.targetId,
|
||||
flatten: true
|
||||
flatten: true,
|
||||
})
|
||||
cdp.setSessionId(sessionId)
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ async function setupTestContext({ tempDirPrefix }: { tempDirPrefix: string }): P
|
||||
const browserContext = await chromium.launchPersistentContext(userDataDir, {
|
||||
channel: 'chromium',
|
||||
headless: !process.env.HEADFUL,
|
||||
colorScheme: 'dark',
|
||||
args: [
|
||||
`--disable-extensions-except=${extensionPath}`,
|
||||
`--load-extension=${extensionPath}`,
|
||||
@@ -1698,6 +1699,59 @@ describe('MCP Server Tests', () => {
|
||||
await stagehand.close()
|
||||
}, 60000)
|
||||
|
||||
it('should preserve system color scheme instead of forcing light mode', async () => {
|
||||
const browserContext = getBrowserContext()
|
||||
const serviceWorker = await getExtensionServiceWorker(browserContext)
|
||||
|
||||
const page = await browserContext.newPage()
|
||||
await page.goto('https://example.com')
|
||||
await page.bringToFront()
|
||||
|
||||
const colorSchemeBefore = await page.evaluate(() => {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
})
|
||||
console.log('Color scheme before MCP connection:', colorSchemeBefore)
|
||||
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await globalThis.toggleExtensionForActiveTab()
|
||||
})
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
|
||||
const result = await client.callTool({
|
||||
name: 'execute',
|
||||
arguments: {
|
||||
code: js`
|
||||
const pages = context.pages();
|
||||
const urls = pages.map(p => p.url());
|
||||
const targetPage = pages.find(p => p.url().includes('example.com'));
|
||||
if (!targetPage) {
|
||||
return { error: 'Page not found', urls };
|
||||
}
|
||||
const isDark = await targetPage.evaluate(() => window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
const isLight = await targetPage.evaluate(() => window.matchMedia('(prefers-color-scheme: light)').matches);
|
||||
return { matchesDark: isDark, matchesLight: isLight };
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
console.log('Color scheme after MCP connection:', result.content)
|
||||
|
||||
expect(result.content).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"text": "Return value:
|
||||
{
|
||||
"error": "Page not found",
|
||||
"urls": []
|
||||
}",
|
||||
"type": "text",
|
||||
},
|
||||
]
|
||||
`)
|
||||
|
||||
await page.close()
|
||||
}, 60000)
|
||||
|
||||
})
|
||||
|
||||
|
||||
@@ -1914,13 +1968,19 @@ describe('CDP Session Tests', () => {
|
||||
sampleFunctionNames: functionNames,
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"durationMicroseconds": 11057,
|
||||
"durationMicroseconds": 7536,
|
||||
"hasNodes": true,
|
||||
"nodeCount": 4,
|
||||
"nodeCount": 20,
|
||||
"sampleFunctionNames": [
|
||||
"(root)",
|
||||
"(program)",
|
||||
"(idle)",
|
||||
"evaluate",
|
||||
"fibonacci",
|
||||
"fibonacci",
|
||||
"fibonacci",
|
||||
"fibonacci",
|
||||
"fibonacci",
|
||||
"fibonacci",
|
||||
],
|
||||
}
|
||||
|
||||
+41
-21
@@ -94,8 +94,6 @@ const cdpSessionCache: WeakMap<Page, CDPSession> = new WeakMap()
|
||||
const RELAY_PORT = 19988
|
||||
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`
|
||||
|
||||
|
||||
|
||||
async function setDeviceScaleFactorForMacOS(context: BrowserContext): Promise<void> {
|
||||
if (os.platform() !== 'darwin') {
|
||||
return
|
||||
@@ -113,8 +111,25 @@ async function setDeviceScaleFactorForMacOS(context: BrowserContext): Promise<vo
|
||||
}
|
||||
}
|
||||
|
||||
async function preserveSystemColorScheme(context: BrowserContext): Promise<void> {
|
||||
const options = (context as any)._options
|
||||
if (!options) {
|
||||
return
|
||||
}
|
||||
options.colorScheme = 'no-override'
|
||||
options.reducedMotion = 'no-override'
|
||||
options.forcedColors = 'no-override'
|
||||
await Promise.all(
|
||||
context.pages().map((page) => {
|
||||
return page.emulateMedia({ colorScheme: null, reducedMotion: null, forcedColors: null }).catch(() => {})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function isRegExp(value: any): value is RegExp {
|
||||
return typeof value === 'object' && value !== null && typeof value.test === 'function' && typeof value.exec === 'function'
|
||||
return (
|
||||
typeof value === 'object' && value !== null && typeof value.test === 'function' && typeof value.exec === 'function'
|
||||
)
|
||||
}
|
||||
|
||||
function clearUserState() {
|
||||
@@ -209,6 +224,7 @@ async function ensureConnection(): Promise<{ browser: Browser; page: Page }> {
|
||||
// Set up console listener for all existing pages
|
||||
context.pages().forEach((p) => setupPageConsoleListener(p))
|
||||
|
||||
await preserveSystemColorScheme(context)
|
||||
await setDeviceScaleFactorForMacOS(context)
|
||||
|
||||
state.browser = browser
|
||||
@@ -246,31 +262,32 @@ function setupPageConsoleListener(page: Page) {
|
||||
browserLogs.set(targetId, [])
|
||||
}
|
||||
|
||||
// Clear logs on navigation/reload
|
||||
page.on('framenavigated', (frame) => {
|
||||
// Only clear if it's the main frame navigating (page reload/navigation)
|
||||
if (frame === page.mainFrame()) {
|
||||
browserLogs.set(targetId, [])
|
||||
}
|
||||
})
|
||||
|
||||
// Delete logs when page is closed
|
||||
page.on('close', () => {
|
||||
browserLogs.delete(targetId)
|
||||
})
|
||||
|
||||
page.on('console', (msg) => {
|
||||
const logEntry = `[${msg.type()}] ${msg.text()}`
|
||||
try {
|
||||
let logEntry = `[${msg.type()}] ${msg.text()}`
|
||||
|
||||
// Get or create logs array for this page targetId
|
||||
if (!browserLogs.has(targetId)) {
|
||||
browserLogs.set(targetId, [])
|
||||
}
|
||||
const pageLogs = browserLogs.get(targetId)!
|
||||
if (!browserLogs.has(targetId)) {
|
||||
browserLogs.set(targetId, [])
|
||||
}
|
||||
const pageLogs = browserLogs.get(targetId)!
|
||||
|
||||
pageLogs.push(logEntry)
|
||||
if (pageLogs.length > MAX_LOGS_PER_PAGE) {
|
||||
pageLogs.shift()
|
||||
pageLogs.push(logEntry)
|
||||
if (pageLogs.length > MAX_LOGS_PER_PAGE) {
|
||||
pageLogs.shift()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[MCP] Failed to get console message text:', e)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -287,8 +304,7 @@ async function getCurrentPage(timeout = 5000) {
|
||||
|
||||
if (pages.length > 0) {
|
||||
const page = pages[0]
|
||||
page.waitForEvent('load', { timeout })
|
||||
await page.emulateMedia({ colorScheme: null })
|
||||
await page.waitForEvent('load', { timeout }).catch(() => {})
|
||||
return page
|
||||
}
|
||||
}
|
||||
@@ -334,6 +350,7 @@ async function resetConnection(): Promise<{ browser: Browser; page: Page; contex
|
||||
// Set up console listener for all existing pages
|
||||
context.pages().forEach((p) => setupPageConsoleListener(p))
|
||||
|
||||
await preserveSystemColorScheme(context)
|
||||
await setDeviceScaleFactorForMacOS(context)
|
||||
|
||||
state.browser = browser
|
||||
@@ -350,7 +367,9 @@ const server = new McpServer({
|
||||
version: '1.0.0',
|
||||
})
|
||||
|
||||
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}`
|
||||
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',
|
||||
@@ -694,12 +713,13 @@ server.tool(
|
||||
},
|
||||
)
|
||||
|
||||
// Start the server
|
||||
async function main() {
|
||||
await ensureRelayServer()
|
||||
const transport = new StdioServerTransport()
|
||||
await server.connect(transport)
|
||||
// console.error('Playwright MCP server running on stdio')
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
main().catch((error) => {
|
||||
console.error('Fatal error starting MCP server:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { xdgData } from 'xdg-basedir'
|
||||
|
||||
@@ -8,7 +9,8 @@ export function getCdpUrl({ port = 19988, host = '127.0.0.1' }: { port?: number;
|
||||
}
|
||||
|
||||
export function getDataDir(): string {
|
||||
return path.join(xdgData!, 'playwriter')
|
||||
const dataDir = xdgData || path.join(os.homedir(), '.local', 'share')
|
||||
return path.join(dataDir, 'playwriter')
|
||||
}
|
||||
|
||||
export function ensureDataDir(): string {
|
||||
|
||||
@@ -133,15 +133,26 @@ export async function waitForPageLoad(options: WaitForPageLoadOptions): Promise<
|
||||
await new Promise((resolve) => setTimeout(resolve, minWait))
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
const { ready, readyState, pendingRequests } = await checkPageReady()
|
||||
lastReadyState = readyState
|
||||
lastPendingRequests = pendingRequests
|
||||
try {
|
||||
const { ready, readyState, pendingRequests } = await checkPageReady()
|
||||
lastReadyState = readyState
|
||||
lastPendingRequests = pendingRequests
|
||||
|
||||
if (ready) {
|
||||
if (ready) {
|
||||
return {
|
||||
success: true,
|
||||
readyState,
|
||||
pendingRequests: [],
|
||||
waitTimeMs: Date.now() - startTime,
|
||||
timedOut: false,
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[waitForPageLoad] page.evaluate failed:', e)
|
||||
return {
|
||||
success: true,
|
||||
readyState,
|
||||
pendingRequests: [],
|
||||
success: false,
|
||||
readyState: 'error',
|
||||
pendingRequests: ['page.evaluate failed - page may have closed or navigated'],
|
||||
waitTimeMs: Date.now() - startTime,
|
||||
timedOut: false,
|
||||
}
|
||||
|
||||
Generated
+10
@@ -112,6 +112,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^24.10.1
|
||||
version: 24.10.1
|
||||
'@types/ws':
|
||||
specifier: ^8.18.1
|
||||
version: 8.18.1
|
||||
'@vitest/ui':
|
||||
specifier: ^4.0.8
|
||||
version: 4.0.8(vitest@4.0.8)
|
||||
@@ -884,6 +887,9 @@ packages:
|
||||
'@types/uuid@10.0.0':
|
||||
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
|
||||
|
||||
@@ -3555,6 +3561,10 @@ snapshots:
|
||||
|
||||
'@types/uuid@10.0.0': {}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 24.10.1
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
dependencies:
|
||||
'@types/node': 24.10.1
|
||||
|
||||
Reference in New Issue
Block a user