fix: discover global @playwright/mcp for nvm/npm installs (#42)

* fix: discover global @playwright/mcp in nvm/npm installs

* test: cover global mcp discovery paths

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
This commit is contained in:
Zhang ShengYanandjackwener authored and GitHub committed 2026-03-17 22:36:06 +08:00
1 parent e0395ce5ed
commit d6222ff932
3 files changed
+148 -11

No files matched your search

+99 -1
View File
@@ -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', () => {
+45 -9
View File
@@ -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;
+4 -1
View File
@@ -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,
};