diff --git a/src/browser.test.ts b/src/browser.test.ts index e3ad022..2e0d03c 100644 --- a/src/browser.test.ts +++ b/src/browser.test.ts @@ -1,6 +1,12 @@ -import { describe, it, expect } from 'vitest'; +import { afterEach, describe, it, expect, vi } from 'vitest'; import { PlaywrightMCP, __test__ } from './browser/index.js'; +afterEach(() => { + __test__.resetMcpServerPathCache(); + __test__.setMcpDiscoveryTestHooks(); + delete process.env.OPENCLI_MCP_SERVER_PATH; +}); + describe('browser helpers', () => { it('creates JSON-RPC requests with unique ids', () => { const first = __test__.createJsonRpcRequest('tools/call', { name: 'browser_tabs' }); @@ -109,6 +115,98 @@ describe('browser helpers', () => { it('times out slow promises', async () => { await expect(__test__.withTimeoutMs(new Promise(() => {}), 10, 'timeout')).rejects.toThrow('timeout'); }); + + it('prefers OPENCLI_MCP_SERVER_PATH over discovered locations', () => { + process.env.OPENCLI_MCP_SERVER_PATH = '/env/mcp/cli.js'; + const existsSync = vi.fn((candidate: any) => candidate === '/env/mcp/cli.js'); + const execSync = vi.fn(); + __test__.setMcpDiscoveryTestHooks({ existsSync, execSync: execSync as any }); + + expect(__test__.findMcpServerPath()).toBe('/env/mcp/cli.js'); + expect(execSync).not.toHaveBeenCalled(); + expect(existsSync).toHaveBeenCalledWith('/env/mcp/cli.js'); + }); + + it('discovers global @playwright/mcp from the current Node runtime prefix', () => { + const originalExecPath = process.execPath; + const runtimeExecPath = '/opt/homebrew/Cellar/node/25.2.1/bin/node'; + const runtimeGlobalMcp = '/opt/homebrew/Cellar/node/25.2.1/lib/node_modules/@playwright/mcp/cli.js'; + Object.defineProperty(process, 'execPath', { + value: runtimeExecPath, + configurable: true, + }); + + const existsSync = vi.fn((candidate: any) => candidate === runtimeGlobalMcp); + const execSync = vi.fn(); + __test__.setMcpDiscoveryTestHooks({ existsSync, execSync: execSync as any }); + + try { + expect(__test__.findMcpServerPath()).toBe(runtimeGlobalMcp); + expect(execSync).not.toHaveBeenCalled(); + expect(existsSync).toHaveBeenCalledWith(runtimeGlobalMcp); + } finally { + Object.defineProperty(process, 'execPath', { + value: originalExecPath, + configurable: true, + }); + } + }); + + it('falls back to npm root -g when runtime prefix lookup misses', () => { + const originalExecPath = process.execPath; + const runtimeExecPath = '/opt/homebrew/Cellar/node/25.2.1/bin/node'; + const runtimeGlobalMcp = '/opt/homebrew/Cellar/node/25.2.1/lib/node_modules/@playwright/mcp/cli.js'; + const npmRootGlobal = '/Users/jakevin/.nvm/versions/node/v22.14.0/lib/node_modules'; + const npmGlobalMcp = '/Users/jakevin/.nvm/versions/node/v22.14.0/lib/node_modules/@playwright/mcp/cli.js'; + Object.defineProperty(process, 'execPath', { + value: runtimeExecPath, + configurable: true, + }); + + const existsSync = vi.fn((candidate: any) => candidate === npmGlobalMcp); + const execSync = vi.fn((command: string) => { + if (String(command).includes('npm root -g')) return `${npmRootGlobal}\n` as any; + throw new Error(`unexpected command: ${String(command)}`); + }); + __test__.setMcpDiscoveryTestHooks({ existsSync, execSync: execSync as any }); + + try { + expect(__test__.findMcpServerPath()).toBe(npmGlobalMcp); + expect(execSync).toHaveBeenCalledOnce(); + expect(existsSync).toHaveBeenCalledWith(runtimeGlobalMcp); + expect(existsSync).toHaveBeenCalledWith(npmGlobalMcp); + } finally { + Object.defineProperty(process, 'execPath', { + value: originalExecPath, + configurable: true, + }); + } + }); + + it('returns null when new global discovery paths are unavailable', () => { + const originalExecPath = process.execPath; + const runtimeExecPath = '/opt/homebrew/Cellar/node/25.2.1/bin/node'; + Object.defineProperty(process, 'execPath', { + value: runtimeExecPath, + configurable: true, + }); + + const existsSync = vi.fn(() => false); + const execSync = vi.fn((command: string) => { + if (String(command).includes('npm root -g')) return '/missing/global/node_modules\n' as any; + throw new Error(`missing command: ${String(command)}`); + }); + __test__.setMcpDiscoveryTestHooks({ existsSync, execSync: execSync as any }); + + try { + expect(__test__.findMcpServerPath()).toBeNull(); + } finally { + Object.defineProperty(process, 'execPath', { + value: originalExecPath, + configurable: true, + }); + } + }); }); describe('PlaywrightMCP state', () => { diff --git a/src/browser/discover.ts b/src/browser/discover.ts index 56f8538..0ee2b53 100644 --- a/src/browser/discover.ts +++ b/src/browser/discover.ts @@ -9,19 +9,33 @@ import * as os from 'node:os'; import * as path from 'node:path'; let _cachedMcpServerPath: string | null | undefined; +let _existsSync = fs.existsSync; +let _execSync = execSync; + +export function resetMcpServerPathCache(): void { + _cachedMcpServerPath = undefined; +} + +export function setMcpDiscoveryTestHooks(input?: { + existsSync?: typeof fs.existsSync; + execSync?: typeof execSync; +}): void { + _existsSync = input?.existsSync ?? fs.existsSync; + _execSync = input?.execSync ?? execSync; +} export function findMcpServerPath(): string | null { if (_cachedMcpServerPath !== undefined) return _cachedMcpServerPath; const envMcp = process.env.OPENCLI_MCP_SERVER_PATH; - if (envMcp && fs.existsSync(envMcp)) { + if (envMcp && _existsSync(envMcp)) { _cachedMcpServerPath = envMcp; return _cachedMcpServerPath; } // Check local node_modules first (@playwright/mcp is the modern package) const localMcp = path.resolve('node_modules', '@playwright', 'mcp', 'cli.js'); - if (fs.existsSync(localMcp)) { + if (_existsSync(localMcp)) { _cachedMcpServerPath = localMcp; return _cachedMcpServerPath; } @@ -29,11 +43,33 @@ export function findMcpServerPath(): string | null { // Check project-relative path const __dirname2 = path.dirname(fileURLToPath(import.meta.url)); const projectMcp = path.resolve(__dirname2, '..', '..', 'node_modules', '@playwright', 'mcp', 'cli.js'); - if (fs.existsSync(projectMcp)) { + if (_existsSync(projectMcp)) { _cachedMcpServerPath = projectMcp; return _cachedMcpServerPath; } + // Check global npm/yarn locations derived from current Node runtime. + const nodePrefix = path.resolve(path.dirname(process.execPath), '..'); + const globalNodeModules = path.join(nodePrefix, 'lib', 'node_modules'); + const globalMcp = path.join(globalNodeModules, '@playwright', 'mcp', 'cli.js'); + if (_existsSync(globalMcp)) { + _cachedMcpServerPath = globalMcp; + return _cachedMcpServerPath; + } + + // Check npm global root directly. + try { + const npmRootGlobal = _execSync('npm root -g 2>/dev/null', { + encoding: 'utf-8', + timeout: 5000, + }).trim(); + const npmGlobalMcp = path.join(npmRootGlobal, '@playwright', 'mcp', 'cli.js'); + if (npmRootGlobal && _existsSync(npmGlobalMcp)) { + _cachedMcpServerPath = npmGlobalMcp; + return _cachedMcpServerPath; + } + } catch {} + // Check common locations const candidates = [ path.join(os.homedir(), '.npm', '_npx'), @@ -43,8 +79,8 @@ export function findMcpServerPath(): string | null { // Try npx resolution (legacy package name) try { - const result = execSync('npx -y --package=@playwright/mcp which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 10000 }).trim(); - if (result && fs.existsSync(result)) { + const result = _execSync('npx -y --package=@playwright/mcp which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 10000 }).trim(); + if (result && _existsSync(result)) { _cachedMcpServerPath = result; return _cachedMcpServerPath; } @@ -52,8 +88,8 @@ export function findMcpServerPath(): string | null { // Try which try { - const result = execSync('which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 5000 }).trim(); - if (result && fs.existsSync(result)) { + const result = _execSync('which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 5000 }).trim(); + if (result && _existsSync(result)) { _cachedMcpServerPath = result; return _cachedMcpServerPath; } @@ -61,9 +97,9 @@ export function findMcpServerPath(): string | null { // Search in common npx cache for (const base of candidates) { - if (!fs.existsSync(base)) continue; + if (!_existsSync(base)) continue; try { - const found = execSync(`find "${base}" -name "cli.js" -path "*playwright*mcp*" 2>/dev/null | head -1`, { encoding: 'utf-8', timeout: 5000 }).trim(); + const found = _execSync(`find "${base}" -name "cli.js" -path "*playwright*mcp*" 2>/dev/null | head -1`, { encoding: 'utf-8', timeout: 5000 }).trim(); if (found) { _cachedMcpServerPath = found; return _cachedMcpServerPath; diff --git a/src/browser/index.ts b/src/browser/index.ts index afe502d..892a992 100644 --- a/src/browser/index.ts +++ b/src/browser/index.ts @@ -13,7 +13,7 @@ export type { ConnectFailureKind, ConnectFailureInput } from './errors.js'; // Test-only helpers — exposed for unit tests import { createJsonRpcRequest } from './mcp.js'; import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js'; -import { buildMcpArgs } from './discover.js'; +import { buildMcpArgs, findMcpServerPath, resetMcpServerPathCache, setMcpDiscoveryTestHooks } from './discover.js'; import { withTimeoutMs } from '../runtime.js'; export const __test__ = { @@ -22,5 +22,8 @@ export const __test__ = { diffTabIndexes, appendLimited, buildMcpArgs, + findMcpServerPath, + resetMcpServerPathCache, + setMcpDiscoveryTestHooks, withTimeoutMs, };