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:
1 parent
e0395ce5ed
commit
d6222ff932
3 files changed
+148
-11
No files matched your search
+99
-1
@@ -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';
|
import { PlaywrightMCP, __test__ } from './browser/index.js';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
__test__.resetMcpServerPathCache();
|
||||||
|
__test__.setMcpDiscoveryTestHooks();
|
||||||
|
delete process.env.OPENCLI_MCP_SERVER_PATH;
|
||||||
|
});
|
||||||
|
|
||||||
describe('browser helpers', () => {
|
describe('browser helpers', () => {
|
||||||
it('creates JSON-RPC requests with unique ids', () => {
|
it('creates JSON-RPC requests with unique ids', () => {
|
||||||
const first = __test__.createJsonRpcRequest('tools/call', { name: 'browser_tabs' });
|
const first = __test__.createJsonRpcRequest('tools/call', { name: 'browser_tabs' });
|
||||||
@@ -109,6 +115,98 @@ describe('browser helpers', () => {
|
|||||||
it('times out slow promises', async () => {
|
it('times out slow promises', async () => {
|
||||||
await expect(__test__.withTimeoutMs(new Promise(() => {}), 10, 'timeout')).rejects.toThrow('timeout');
|
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', () => {
|
describe('PlaywrightMCP state', () => {
|
||||||
|
|||||||
+45
-9
@@ -9,19 +9,33 @@ import * as os from 'node:os';
|
|||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
|
|
||||||
let _cachedMcpServerPath: string | null | undefined;
|
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 {
|
export function findMcpServerPath(): string | null {
|
||||||
if (_cachedMcpServerPath !== undefined) return _cachedMcpServerPath;
|
if (_cachedMcpServerPath !== undefined) return _cachedMcpServerPath;
|
||||||
|
|
||||||
const envMcp = process.env.OPENCLI_MCP_SERVER_PATH;
|
const envMcp = process.env.OPENCLI_MCP_SERVER_PATH;
|
||||||
if (envMcp && fs.existsSync(envMcp)) {
|
if (envMcp && _existsSync(envMcp)) {
|
||||||
_cachedMcpServerPath = envMcp;
|
_cachedMcpServerPath = envMcp;
|
||||||
return _cachedMcpServerPath;
|
return _cachedMcpServerPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check local node_modules first (@playwright/mcp is the modern package)
|
// Check local node_modules first (@playwright/mcp is the modern package)
|
||||||
const localMcp = path.resolve('node_modules', '@playwright', 'mcp', 'cli.js');
|
const localMcp = path.resolve('node_modules', '@playwright', 'mcp', 'cli.js');
|
||||||
if (fs.existsSync(localMcp)) {
|
if (_existsSync(localMcp)) {
|
||||||
_cachedMcpServerPath = localMcp;
|
_cachedMcpServerPath = localMcp;
|
||||||
return _cachedMcpServerPath;
|
return _cachedMcpServerPath;
|
||||||
}
|
}
|
||||||
@@ -29,11 +43,33 @@ export function findMcpServerPath(): string | null {
|
|||||||
// Check project-relative path
|
// Check project-relative path
|
||||||
const __dirname2 = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname2 = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const projectMcp = path.resolve(__dirname2, '..', '..', 'node_modules', '@playwright', 'mcp', 'cli.js');
|
const projectMcp = path.resolve(__dirname2, '..', '..', 'node_modules', '@playwright', 'mcp', 'cli.js');
|
||||||
if (fs.existsSync(projectMcp)) {
|
if (_existsSync(projectMcp)) {
|
||||||
_cachedMcpServerPath = projectMcp;
|
_cachedMcpServerPath = projectMcp;
|
||||||
return _cachedMcpServerPath;
|
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
|
// Check common locations
|
||||||
const candidates = [
|
const candidates = [
|
||||||
path.join(os.homedir(), '.npm', '_npx'),
|
path.join(os.homedir(), '.npm', '_npx'),
|
||||||
@@ -43,8 +79,8 @@ export function findMcpServerPath(): string | null {
|
|||||||
|
|
||||||
// Try npx resolution (legacy package name)
|
// Try npx resolution (legacy package name)
|
||||||
try {
|
try {
|
||||||
const result = execSync('npx -y --package=@playwright/mcp which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 10000 }).trim();
|
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)) {
|
if (result && _existsSync(result)) {
|
||||||
_cachedMcpServerPath = result;
|
_cachedMcpServerPath = result;
|
||||||
return _cachedMcpServerPath;
|
return _cachedMcpServerPath;
|
||||||
}
|
}
|
||||||
@@ -52,8 +88,8 @@ export function findMcpServerPath(): string | null {
|
|||||||
|
|
||||||
// Try which
|
// Try which
|
||||||
try {
|
try {
|
||||||
const result = execSync('which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 5000 }).trim();
|
const result = _execSync('which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 5000 }).trim();
|
||||||
if (result && fs.existsSync(result)) {
|
if (result && _existsSync(result)) {
|
||||||
_cachedMcpServerPath = result;
|
_cachedMcpServerPath = result;
|
||||||
return _cachedMcpServerPath;
|
return _cachedMcpServerPath;
|
||||||
}
|
}
|
||||||
@@ -61,9 +97,9 @@ export function findMcpServerPath(): string | null {
|
|||||||
|
|
||||||
// Search in common npx cache
|
// Search in common npx cache
|
||||||
for (const base of candidates) {
|
for (const base of candidates) {
|
||||||
if (!fs.existsSync(base)) continue;
|
if (!_existsSync(base)) continue;
|
||||||
try {
|
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) {
|
if (found) {
|
||||||
_cachedMcpServerPath = found;
|
_cachedMcpServerPath = found;
|
||||||
return _cachedMcpServerPath;
|
return _cachedMcpServerPath;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export type { ConnectFailureKind, ConnectFailureInput } from './errors.js';
|
|||||||
// Test-only helpers — exposed for unit tests
|
// Test-only helpers — exposed for unit tests
|
||||||
import { createJsonRpcRequest } from './mcp.js';
|
import { createJsonRpcRequest } from './mcp.js';
|
||||||
import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.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';
|
import { withTimeoutMs } from '../runtime.js';
|
||||||
|
|
||||||
export const __test__ = {
|
export const __test__ = {
|
||||||
@@ -22,5 +22,8 @@ export const __test__ = {
|
|||||||
diffTabIndexes,
|
diffTabIndexes,
|
||||||
appendLimited,
|
appendLimited,
|
||||||
buildMcpArgs,
|
buildMcpArgs,
|
||||||
|
findMcpServerPath,
|
||||||
|
resetMcpServerPathCache,
|
||||||
|
setMcpDiscoveryTestHooks,
|
||||||
withTimeoutMs,
|
withTimeoutMs,
|
||||||
};
|
};
|
||||||
Reference in new issue
Block a user