diff --git a/package-lock.json b/package-lock.json index f32db4e..8237af6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@jackwener/opencli", - "version": "0.4.5", + "version": "0.4.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@jackwener/opencli", - "version": "0.4.5", + "version": "0.4.6", "license": "BSD-3-Clause", "dependencies": { "chalk": "^5.3.0", diff --git a/package.json b/package.json index 2aac264..10a3863 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jackwener/opencli", - "version": "0.4.5", + "version": "0.4.6", "publishConfig": { "access": "public" }, diff --git a/src/browser.test.ts b/src/browser.test.ts index 1d4e3f7..c80fc19 100644 --- a/src/browser.test.ts +++ b/src/browser.test.ts @@ -74,4 +74,14 @@ describe('PlaywrightMCP state', () => { await expect(mcp.connect()).rejects.toThrow('Playwright MCP is closing'); }); + + it('tracks backend mode for lifecycle policy', async () => { + const mcp = new PlaywrightMCP(); + + expect((mcp as any)._mode).toBeNull(); + + await mcp.close(); + + expect((mcp as any)._mode).toBeNull(); + }); }); diff --git a/src/browser.ts b/src/browser.ts index 7367a1e..5773627 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -5,6 +5,7 @@ import { spawn, execSync, type ChildProcess } from 'node:child_process'; import { createHash } from 'node:crypto'; +import * as http from 'node:http'; import * as net from 'node:net'; import { fileURLToPath } from 'node:url'; import * as fs from 'node:fs'; @@ -34,6 +35,33 @@ function isPortReachable(port: number, host = '127.0.0.1', timeoutMs = 800): Pro }); } +/** + * Verify the CDP HTTP JSON API is functional. + * Chrome's chrome://inspect#remote-debugging mode writes DevToolsActivePort + * but doesn't expose the full CDP HTTP API (/json/version), which means + * Playwright's connectOverCDP won't work properly (init succeeds but + * all tool calls hang silently). + */ +export function isCdpApiAvailable(port: number, host = '127.0.0.1', timeoutMs = 2000): Promise { + return new Promise(resolve => { + const req = http.get(`http://${host}:${port}/json/version`, { timeout: timeoutMs }, (res) => { + let body = ''; + res.on('data', (chunk: Buffer) => { body += chunk.toString(); }); + res.on('end', () => { + try { + const data = JSON.parse(body); + // A valid CDP endpoint returns { Browser, ... } with a webSocketDebuggerUrl + resolve(!!data && typeof data === 'object' && !!data.Browser); + } catch { + resolve(false); + } + }); + }); + req.on('error', () => resolve(false)); + req.on('timeout', () => { req.destroy(); resolve(false); }); + }); +} + export async function discoverChromeEndpoint(): Promise { const candidates: string[] = []; @@ -67,7 +95,12 @@ export async function discoverChromeEndpoint(): Promise { const endpoint = `ws://127.0.0.1:${port}${browserPath}`; // Verify the port is actually reachable (Chrome may have closed, leaving a stale file) if (await isPortReachable(port)) { - return endpoint; + // Verify CDP HTTP API is functional — chrome://inspect#remote-debugging + // writes DevToolsActivePort but doesn't expose the full CDP API, + // causing Playwright connectOverCDP to hang on all tool calls. + if (await isCdpApiAvailable(port)) { + return endpoint; + } } } } @@ -82,11 +115,14 @@ const PKG_VERSION = (() => { try { return JSON.parse(fs.readFileSync(path.resolv const CONNECT_TIMEOUT = parseInt(process.env.OPENCLI_BROWSER_CONNECT_TIMEOUT ?? '30', 10); const STDERR_BUFFER_LIMIT = 16 * 1024; +const INITIAL_TABS_TIMEOUT_MS = 1500; +const CDP_READINESS_PROBE_TIMEOUT_MS = 5000; const TAB_CLEANUP_TIMEOUT_MS = 2000; let _cachedMcpServerPath: string | null | undefined; type ConnectFailureKind = 'missing-token' | 'extension-timeout' | 'extension-not-installed' | 'cdp-timeout' | 'mcp-init' | 'process-exit' | 'unknown'; type PlaywrightMCPState = 'idle' | 'connecting' | 'connected' | 'closing' | 'closed'; +type PlaywrightMCPMode = 'extension' | 'cdp' | null; type ConnectFailureInput = { kind: ConnectFailureKind; @@ -425,6 +461,7 @@ export class PlaywrightMCP { private _initialTabIdentities: string[] = []; private _closingPromise: Promise | null = null; private _state: PlaywrightMCPState = 'idle'; + private _mode: PlaywrightMCPMode = null; private _page: Page | null = null; @@ -459,6 +496,7 @@ export class PlaywrightMCP { this._page = null; this._proc = null; this._buffer = ''; + this._mode = null; this._initialTabIdentities = []; this._rejectPendingRequests(new Error('Playwright MCP connect failed')); PlaywrightMCP._activeInsts.delete(this); @@ -500,6 +538,7 @@ export class PlaywrightMCP { const isDebug = process.env.DEBUG?.includes('opencli:mcp'); const debugLog = (msg: string) => isDebug && console.error(`[opencli:mcp] ${msg}`); const mode: 'extension' | 'cdp' = cdpEndpoint ? 'cdp' : 'extension'; + this._mode = mode; const extensionToken = process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN; const tokenFingerprint = getTokenFingerprint(extensionToken); let stderrBuffer = ''; @@ -636,9 +675,31 @@ export class PlaywrightMCP { debugLog(`SEND: ${initializedMsg.trim()}`); this._proc?.stdin?.write(initializedMsg); - // Get initial tab count for cleanup (with timeout — CDP mode may hang on browser_tabs) + if (mode === 'cdp') { + // CDP readiness probe: verify tool calls actually work. + // Some CDP endpoints (e.g. chrome://inspect mode) accept WebSocket + // connections and respond to MCP init but silently drop tool calls. + debugLog('CDP readiness probe (tabs)...'); + withTimeout(page.tabs(), CDP_READINESS_PROBE_TIMEOUT_MS, 'CDP readiness probe timed out') + .then(() => { + debugLog('CDP readiness probe succeeded'); + settleSuccess(page); + }) + .catch((err) => { + debugLog(`CDP readiness probe failed: ${err.message}`); + settleError('cdp-timeout', { + rawMessage: 'CDP endpoint connected but tool calls are unresponsive. ' + + 'This usually means Chrome was opened with chrome://inspect#remote-debugging ' + + 'which is not fully compatible. Launch Chrome with --remote-debugging-port=9222 instead, ' + + 'or use the Playwright MCP Bridge extension (default mode).', + }); + }); + return; + } + + // Extension mode uses tabs as a readiness probe and for tab cleanup bookkeeping. debugLog('Fetching initial tabs count...'); - withTimeout(page.tabs(), 5000, 'Timed out fetching initial tabs').then((tabs: any) => { + withTimeout(page.tabs(), INITIAL_TABS_TIMEOUT_MS, 'Timed out fetching initial tabs').then((tabs: any) => { debugLog(`Tabs response: ${typeof tabs === 'string' ? tabs : JSON.stringify(tabs)}`); this._initialTabIdentities = extractTabIdentities(tabs); settleSuccess(page); @@ -659,8 +720,8 @@ export class PlaywrightMCP { this._state = 'closing'; this._closingPromise = (async () => { try { - // Close tabs opened during this session (site tabs + extension tabs) - if (this._page && this._proc && !this._proc.killed) { + // Extension mode opens bridge/session tabs that we can clean up best-effort. + if (this._mode === 'extension' && this._page && this._proc && !this._proc.killed) { try { const tabs = await withTimeout(this._page.tabs(), TAB_CLEANUP_TIMEOUT_MS, 'Timed out fetching tabs during cleanup'); const tabEntries = extractTabEntries(tabs); @@ -690,6 +751,7 @@ export class PlaywrightMCP { this._rejectPendingRequests(new Error('Playwright MCP session closed')); this._page = null; this._proc = null; + this._mode = null; this._state = 'closed'; PlaywrightMCP._activeInsts.delete(this); } @@ -782,6 +844,7 @@ export const __test__ = { diffTabIndexes, appendLimited, withTimeout, + isCdpApiAvailable, }; function findMcpServerPath(): string | null {