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';
|
||||
|
||||
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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
Reference in new issue
Block a user