Compare commits

...

3 Commits

Author SHA1 Message Date
jackwener 1f998e4ff0 feat(browser): migrate to self-hosted extension and remove token configuration 2026-03-18 11:49:15 +08:00
jackwener 36b06e9f32 test: cover global mcp discovery paths 2026-03-17 22:27:48 +08:00
Sheng-Yan, Zhang f0273f94bb fix: discover global @playwright/mcp in nvm/npm installs 2026-03-17 19:05:45 +08:00
11 changed files with 239 additions and 630 deletions
+25 -65
View File
@@ -19,9 +19,9 @@
"opencli": "dist/main.js"
},
"devDependencies": {
"@playwright/mcp": "^0.0.68",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.13.10",
"opencli-mcp": "file:../opencli-mcp/packages/playwright-mcp",
"tsx": "^4.19.3",
"typescript": "^5.8.2",
"vitest": "^4.1.0"
@@ -30,6 +30,22 @@
"node": ">=18.0.0"
}
},
"../opencli-mcp/packages/playwright-mcp": {
"name": "opencli-mcp",
"version": "0.0.68",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.59.0-alpha-1771104257000",
"playwright-core": "1.59.0-alpha-1771104257000"
},
"bin": {
"playwright-mcp": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@colors/colors": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
@@ -560,23 +576,6 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@playwright/mcp": {
"version": "0.0.68",
"resolved": "https://registry.npmjs.org/@playwright/mcp/-/mcp-0.0.68.tgz",
"integrity": "sha512-oP9I9ghXKuQEBo4xaC7HgsS2gRTxyMzlBm3UEhYj4VqqrqbPQUX2shATPaNA/am9joBzq9v0OXISzeIgP+zmHA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.59.0-alpha-1771104257000",
"playwright-core": "1.59.0-alpha-1771104257000"
},
"bin": {
"playwright-mcp": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz",
@@ -895,6 +894,7 @@
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -1543,6 +1543,10 @@
],
"license": "MIT"
},
"node_modules/opencli-mcp": {
"resolved": "../opencli-mcp/packages/playwright-mcp",
"link": true
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -1563,6 +1567,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -1570,53 +1575,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.59.0-alpha-1771104257000",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.0-alpha-1771104257000.tgz",
"integrity": "sha512-6SCMMMJaDRsSqiKVLmb2nhtLES7iTYawTWWrQK6UdIGNzXi8lka4sLKRec3L4DnTWwddAvCuRn8035dhNiHzbg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.0-alpha-1771104257000"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.59.0-alpha-1771104257000",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.0-alpha-1771104257000.tgz",
"integrity": "sha512-YiXup3pnpQUCBMSIW5zx8CErwRx4K6O5Kojkw2BzJui8MazoMUDU6E3xGsb1kzFviEAE09LFQ+y1a0RhIJQ5SA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
@@ -1805,6 +1763,7 @@
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
@@ -1846,6 +1805,7 @@
"integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@oxc-project/runtime": "0.115.0",
"lightningcss": "^1.32.0",
+1 -1
View File
@@ -47,9 +47,9 @@
"js-yaml": "^4.1.0"
},
"devDependencies": {
"@playwright/mcp": "^0.0.68",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.13.10",
"opencli-mcp": "file:../opencli-mcp/packages/playwright-mcp",
"tsx": "^4.19.3",
"typescript": "^5.8.2",
"vitest": "^4.1.0"
+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 opencli-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/opencli-mcp/packages/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/opencli-mcp/packages/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/opencli-mcp/packages/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', () => {
+48 -12
View File
@@ -9,31 +9,67 @@ 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)) {
// Check local node_modules first (opencli-mcp is the modern package)
const localMcp = path.resolve('node_modules', 'opencli-mcp', 'cli.js');
if (_existsSync(localMcp)) {
_cachedMcpServerPath = localMcp;
return _cachedMcpServerPath;
}
// 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)) {
const projectMcp = path.resolve(__dirname2, '..', '..', 'node_modules', 'opencli-mcp', 'cli.js');
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, 'opencli-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, 'opencli-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=opencli-mcp which opencli-mcp 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 opencli-mcp 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 "*opencli*mcp*" 2>/dev/null | head -1`, { encoding: 'utf-8', timeout: 5000 }).trim();
if (found) {
_cachedMcpServerPath = found;
return _cachedMcpServerPath;
+9 -33
View File
@@ -2,66 +2,45 @@
* Browser connection error classification and formatting.
*/
import { createHash } from 'node:crypto';
export type ConnectFailureKind = 'missing-token' | 'extension-timeout' | 'extension-not-installed' | 'mcp-init' | 'process-exit' | 'unknown';
export type ConnectFailureKind = 'extension-timeout' | 'extension-not-installed' | 'mcp-init' | 'process-exit' | 'unknown';
export type ConnectFailureInput = {
kind: ConnectFailureKind;
timeout: number;
hasExtensionToken: boolean;
tokenFingerprint?: string | null;
stderr?: string;
exitCode?: number | null;
rawMessage?: string;
};
export function getTokenFingerprint(token: string | undefined): string | null {
if (!token) return null;
return createHash('sha256').update(token).digest('hex').slice(0, 8);
}
export function formatBrowserConnectError(input: ConnectFailureInput): Error {
const stderr = input.stderr?.trim();
const suffix = stderr ? `\n\nMCP stderr:\n${stderr}` : '';
const tokenHint = input.tokenFingerprint ? ` Token fingerprint: ${input.tokenFingerprint}.` : '';
if (input.kind === 'missing-token') {
return new Error(
'Failed to connect to Playwright MCP Bridge: PLAYWRIGHT_MCP_EXTENSION_TOKEN is not set.\n\n' +
'Without this token, Chrome will show a manual approval dialog for every new MCP connection. ' +
'Copy the token from the Playwright MCP Bridge extension and set it in BOTH your shell environment and MCP client config.' +
suffix,
);
}
if (input.kind === 'extension-not-installed') {
return new Error(
'Failed to connect to Playwright MCP Bridge: the browser extension did not attach.\n\n' +
'Make sure Chrome is running and the "Playwright MCP Bridge" extension is installed and enabled. ' +
'If Chrome shows an approval dialog, click Allow.' +
'Failed to connect to OpenCLI MCP Bridge: the browser extension did not attach.\n\n' +
'Make sure Chrome is running and the "OpenCLI MCP Bridge" extension is installed and enabled in Developer Mode.' +
suffix,
);
}
if (input.kind === 'extension-timeout') {
const likelyCause = input.hasExtensionToken
? `The most likely cause is that PLAYWRIGHT_MCP_EXTENSION_TOKEN does not match the token currently shown by the browser extension.${tokenHint} Re-copy the token from the extension and update BOTH your shell environment and MCP client config.`
: 'PLAYWRIGHT_MCP_EXTENSION_TOKEN is not configured, so the extension may be waiting for manual approval.';
return new Error(
`Timed out connecting to Playwright MCP Bridge (${input.timeout}s).\n\n` +
`${likelyCause} If a browser prompt is visible, click Allow.` +
`Timed out connecting to OpenCLI MCP Bridge (${input.timeout}s).\n\n` +
`Make sure Chrome is running with the OpenCLI MCP Bridge extension enabled.` +
suffix,
);
}
if (input.kind === 'mcp-init') {
return new Error(`Failed to initialize Playwright MCP: ${input.rawMessage ?? 'unknown error'}${suffix}`);
return new Error(`Failed to initialize OpenCLI MCP: ${input.rawMessage ?? 'unknown error'}${suffix}`);
}
if (input.kind === 'process-exit') {
return new Error(
`Playwright MCP process exited before the browser connection was established${input.exitCode == null ? '' : ` (code ${input.exitCode})`}.` +
`OpenCLI MCP process exited before the browser connection was established${input.exitCode == null ? '' : ` (code ${input.exitCode})`}.` +
suffix,
);
}
@@ -70,16 +49,13 @@ export function formatBrowserConnectError(input: ConnectFailureInput): Error {
}
export function inferConnectFailureKind(args: {
hasExtensionToken: boolean;
stderr: string;
rawMessage?: string;
exited?: boolean;
}): ConnectFailureKind {
const haystack = `${args.rawMessage ?? ''}\n${args.stderr}`.toLowerCase();
if (!args.hasExtensionToken)
return 'missing-token';
if (haystack.includes('extension connection timeout') || haystack.includes('playwright mcp bridge'))
if (haystack.includes('extension connection timeout') || haystack.includes('opencli mcp bridge') || haystack.includes('playwright mcp bridge'))
return 'extension-not-installed';
if (args.rawMessage?.startsWith('MCP init failed:'))
return 'mcp-init';
+5 -2
View File
@@ -7,13 +7,13 @@
export { Page } from './page.js';
export { PlaywrightMCP } from './mcp.js';
export { getTokenFingerprint, formatBrowserConnectError } from './errors.js';
export { formatBrowserConnectError } from './errors.js';
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,
};
+4 -11
View File
@@ -8,7 +8,7 @@ import type { IPage } from '../types.js';
import { withTimeoutMs, DEFAULT_BROWSER_CONNECT_TIMEOUT } from '../runtime.js';
import { PKG_VERSION } from '../version.js';
import { Page } from './page.js';
import { getTokenFingerprint, formatBrowserConnectError, inferConnectFailureKind } from './errors.js';
import { formatBrowserConnectError, inferConnectFailureKind } from './errors.js';
import { findMcpServerPath, buildMcpArgs } from './discover.js';
import { extractTabIdentities, extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
@@ -115,9 +115,8 @@ export class PlaywrightMCP {
return new Promise<Page>((resolve, reject) => {
const isDebug = process.env.DEBUG?.includes('opencli:mcp');
const debugLog = (msg: string) => isDebug && console.error(`[opencli:mcp] ${msg}`);
const useExtension = !!process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
const extensionToken = process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
const tokenFingerprint = getTokenFingerprint(extensionToken);
const useExtension = true; // Always true in dev config or local for opencli-mcp
let stderrBuffer = '';
let settled = false;
@@ -130,8 +129,6 @@ export class PlaywrightMCP {
reject(formatBrowserConnectError({
kind,
timeout,
hasExtensionToken: !!extensionToken,
tokenFingerprint,
stderr: stderrBuffer,
exitCode: extra.exitCode,
rawMessage: extra.rawMessage,
@@ -149,7 +146,6 @@ export class PlaywrightMCP {
const timer = setTimeout(() => {
debugLog('Connection timed out');
settleError(inferConnectFailureKind({
hasExtensionToken: !!extensionToken,
stderr: stderrBuffer,
}));
}, timeout * 1000);
@@ -159,8 +155,7 @@ export class PlaywrightMCP {
executablePath: process.env.OPENCLI_BROWSER_EXECUTABLE_PATH,
});
if (process.env.OPENCLI_VERBOSE) {
console.error(`[opencli] Mode: ${useExtension ? 'extension' : 'standalone'}`);
if (useExtension) console.error(`[opencli] Extension token: fingerprint ${tokenFingerprint}`);
console.error(`[opencli] Mode: extension`);
}
debugLog(`Spawning node ${mcpArgs.join(' ')}`);
@@ -213,7 +208,6 @@ export class PlaywrightMCP {
this._rejectPendingRequests(new Error(`Playwright MCP process exited before response${code == null ? '' : ` (code ${code})`}`));
if (!settled) {
settleError(inferConnectFailureKind({
hasExtensionToken: !!extensionToken,
stderr: stderrBuffer,
exited: true,
}), { exitCode: code });
@@ -230,7 +224,6 @@ export class PlaywrightMCP {
debugLog('Got initialize response');
if (resp.error) {
settleError(inferConnectFailureKind({
hasExtensionToken: !!extensionToken,
stderr: stderrBuffer,
rawMessage: `MCP init failed: ${resp.error.message}`,
}), { rawMessage: resp.error.message });
+12 -147
View File
@@ -2,135 +2,12 @@ import { describe, expect, it } from 'vitest';
import {
readTokenFromShellContent,
renderBrowserDoctorReport,
upsertShellToken,
readTomlConfigToken,
upsertTomlConfigToken,
upsertJsonConfigToken,
} from './doctor.js';
describe('shell token helpers', () => {
it('reads token from shell export', () => {
expect(readTokenFromShellContent('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="abc123"\n')).toBe('abc123');
});
it('appends token export when missing', () => {
const next = upsertShellToken('export PATH="/usr/bin"\n', 'abc123');
expect(next).toContain('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="abc123"');
});
it('replaces token export when present', () => {
const next = upsertShellToken('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="old"\n', 'new');
expect(next).toContain('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="new"');
expect(next).not.toContain('"old"');
});
});
describe('toml token helpers', () => {
it('reads token from playwright env section', () => {
const content = `
[mcp_servers.playwright.env]
PLAYWRIGHT_MCP_EXTENSION_TOKEN = "abc123"
`;
expect(readTomlConfigToken(content)).toBe('abc123');
});
it('updates token inside existing env section', () => {
const content = `
[mcp_servers.playwright.env]
PLAYWRIGHT_MCP_EXTENSION_TOKEN = "old"
`;
const next = upsertTomlConfigToken(content, 'new');
expect(next).toContain('PLAYWRIGHT_MCP_EXTENSION_TOKEN = "new"');
expect(next).not.toContain('"old"');
});
it('creates env section when missing', () => {
const content = `
[mcp_servers.playwright]
type = "stdio"
`;
const next = upsertTomlConfigToken(content, 'abc123');
expect(next).toContain('[mcp_servers.playwright.env]');
expect(next).toContain('PLAYWRIGHT_MCP_EXTENSION_TOKEN = "abc123"');
});
});
describe('json token helpers', () => {
it('writes token into standard mcpServers config', () => {
const next = upsertJsonConfigToken(JSON.stringify({
mcpServers: {
playwright: {
command: 'npx',
args: ['-y', '@playwright/mcp@latest', '--extension'],
},
},
}), 'abc123');
const parsed = JSON.parse(next);
expect(parsed.mcpServers.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
});
it('writes token into opencode mcp config', () => {
const next = upsertJsonConfigToken(JSON.stringify({
$schema: 'https://opencode.ai/config.json',
mcp: {
playwright: {
command: ['npx', '-y', '@playwright/mcp@latest', '--extension'],
enabled: true,
type: 'local',
},
},
}), 'abc123');
const parsed = JSON.parse(next);
expect(parsed.mcp.playwright.environment.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
});
it('creates standard mcpServers format for empty file (not OpenCode)', () => {
const next = upsertJsonConfigToken('', 'abc123');
const parsed = JSON.parse(next);
expect(parsed.mcpServers.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
expect(parsed.mcp).toBeUndefined();
});
it('creates OpenCode format when filePath contains opencode', () => {
const next = upsertJsonConfigToken('', 'abc123', '/home/user/.config/opencode/opencode.json');
const parsed = JSON.parse(next);
expect(parsed.mcp.playwright.environment.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
expect(parsed.mcpServers).toBeUndefined();
});
it('creates standard format when filePath is claude.json', () => {
const next = upsertJsonConfigToken('', 'abc123', '/home/user/.claude.json');
const parsed = JSON.parse(next);
expect(parsed.mcpServers.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
});
});
describe('fish shell support', () => {
it('generates fish set -gx syntax for fish config path', () => {
const next = upsertShellToken('', 'abc123', '/home/user/.config/fish/config.fish');
expect(next).toContain('set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "abc123"');
expect(next).not.toContain('export');
});
it('replaces existing fish set line', () => {
const content = 'set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "old"\n';
const next = upsertShellToken(content, 'new', '/home/user/.config/fish/config.fish');
expect(next).toContain('set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "new"');
expect(next).not.toContain('"old"');
});
it('appends fish syntax to existing fish config', () => {
const content = 'set -gx PATH /usr/bin\n';
const next = upsertShellToken(content, 'abc123', '/home/user/.config/fish/config.fish');
expect(next).toContain('set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "abc123"');
expect(next).toContain('set -gx PATH /usr/bin');
});
it('uses export syntax for zshrc even with filePath', () => {
const next = upsertShellToken('', 'abc123', '/home/user/.zshrc');
expect(next).toContain('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="abc123"');
expect(next).not.toContain('set -gx');
});
});
describe('doctor report rendering', () => {
@@ -139,60 +16,51 @@ describe('doctor report rendering', () => {
it('renders OK-style report when tokens match', () => {
const text = strip(renderBrowserDoctorReport({
envToken: 'abc123',
envFingerprint: 'fp1',
extensionToken: 'abc123',
extensionFingerprint: 'fp1',
extensionInstalled: true,
extensionBrowsers: ['Chrome'],
shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'abc123', fingerprint: 'fp1' }],
configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'abc123' }],
configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', writable: true }],
recommendedToken: 'abc123',
recommendedFingerprint: 'fp1',
warnings: [],
issues: [],
}));
expect(text).toContain('[OK] Extension installed (Chrome)');
expect(text).toContain('[OK] Environment token: configured (fp1)');
expect(text).toContain('[OK] Environment token: configured');
expect(text).toContain('[OK] /tmp/mcp.json');
expect(text).toContain('configured (fp1)');
expect(text).toContain('configured');
});
it('renders MISMATCH-style report when fingerprints differ', () => {
it('renders MISSING-style report when components are not installed', () => {
const text = strip(renderBrowserDoctorReport({
envToken: 'abc123',
envFingerprint: 'fp1',
extensionToken: null,
extensionFingerprint: null,
extensionInstalled: false,
extensionBrowsers: [],
shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'def456', fingerprint: 'fp2' }],
configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'def456' }],
configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', writable: true }],
recommendedToken: 'abc123',
recommendedFingerprint: 'fp1',
warnings: [],
issues: ['Detected inconsistent Playwright MCP tokens across env/config files.'],
issues: [],
}));
expect(text).toContain('[MISSING] Extension not installed in any browser');
expect(text).toContain('[MISMATCH] Environment token: configured (fp1)');
expect(text).toContain('[MISMATCH] /tmp/.zshrc');
expect(text).toContain('configured (fp2)');
expect(text).toContain('[MISMATCH] Recommended token fingerprint: fp1');
expect(text).toContain('[OK] Environment token: configured');
expect(text).toContain('[OK] /tmp/.zshrc');
expect(text).toContain('configured');
expect(text).toContain('[OK] Token Configuration: Not required for OpenCLI MCP');
});
it('renders connectivity OK when live test succeeds', () => {
const text = strip(renderBrowserDoctorReport({
envToken: 'abc123',
envFingerprint: 'fp1',
extensionToken: 'abc123',
extensionFingerprint: 'fp1',
extensionInstalled: true,
extensionBrowsers: ['Chrome'],
shellFiles: [],
configs: [],
recommendedToken: 'abc123',
recommendedFingerprint: 'fp1',
connectivity: { ok: true, durationMs: 1234 },
warnings: [],
issues: [],
@@ -204,15 +72,12 @@ describe('doctor report rendering', () => {
it('renders connectivity WARN when not tested', () => {
const text = strip(renderBrowserDoctorReport({
envToken: 'abc123',
envFingerprint: 'fp1',
extensionToken: 'abc123',
extensionFingerprint: 'fp1',
extensionInstalled: true,
extensionBrowsers: ['Chrome'],
shellFiles: [],
configs: [],
recommendedToken: 'abc123',
recommendedFingerprint: 'fp1',
warnings: [],
issues: [],
}));
+22 -177
View File
@@ -6,7 +6,7 @@ import { createInterface } from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';
import chalk from 'chalk';
import type { IPage } from './types.js';
import { PlaywrightMCP, getTokenFingerprint } from './browser/index.js';
import { PlaywrightMCP } from './browser/index.js';
import { browserSession } from './runtime.js';
const PLAYWRIGHT_SERVER_NAME = 'playwright';
@@ -27,7 +27,6 @@ export type ShellFileStatus = {
path: string;
exists: boolean;
token: string | null;
fingerprint: string | null;
};
export type McpConfigFormat = 'json' | 'toml';
@@ -37,7 +36,6 @@ export type McpConfigStatus = {
exists: boolean;
format: McpConfigFormat;
token: string | null;
fingerprint: string | null;
writable: boolean;
parseError?: string;
};
@@ -51,15 +49,12 @@ export type ConnectivityResult = {
export type DoctorReport = {
cliVersion?: string;
envToken: string | null;
envFingerprint: string | null;
extensionToken: string | null;
extensionFingerprint: string | null;
extensionInstalled: boolean;
extensionBrowsers: string[];
shellFiles: ShellFileStatus[];
configs: McpConfigStatus[];
recommendedToken: string | null;
recommendedFingerprint: string | null;
connectivity?: ConnectivityResult;
warnings: string[];
issues: string[];
@@ -80,9 +75,9 @@ function statusLine(status: ReportStatus, text: string): string {
return `${colorLabel(status)} ${text}`;
}
function tokenSummary(token: string | null, fingerprint: string | null): string {
function tokenSummary(token: string | null): string {
if (!token) return chalk.dim('missing');
return `configured ${chalk.dim(`(${fingerprint})`)}`;
return `configured`;
}
export function shortenPath(p: string): string {
@@ -145,97 +140,7 @@ export function readTokenFromShellContent(content: string): string | null {
return m?.[3] ?? null;
}
export function upsertShellToken(content: string, token: string, filePath?: string): string {
if (filePath && isFishConfig(filePath)) {
// Fish shell uses `set -gx` instead of `export`
const fishLine = `set -gx ${PLAYWRIGHT_TOKEN_ENV} "${token}"`;
const fishRe = /^\s*set\s+(-gx\s+)?PLAYWRIGHT_MCP_EXTENSION_TOKEN\s+.*/m;
if (!content.trim()) return `${fishLine}\n`;
if (fishRe.test(content)) return content.replace(fishRe, fishLine);
return `${content.replace(/\s*$/, '')}\n${fishLine}\n`;
}
const nextLine = `export ${PLAYWRIGHT_TOKEN_ENV}="${token}"`;
if (!content.trim()) return `${nextLine}\n`;
if (TOKEN_LINE_RE.test(content)) return content.replace(TOKEN_LINE_RE, `$1"${
token
}"`);
return `${content.replace(/\s*$/, '')}\n${nextLine}\n`;
}
function readJsonConfigToken(content: string): string | null {
try {
const parsed = JSON.parse(content);
return readTokenFromJsonObject(parsed);
} catch {
return null;
}
}
function readTokenFromJsonObject(parsed: any): string | null {
const direct = parsed?.mcpServers?.[PLAYWRIGHT_SERVER_NAME]?.env?.[PLAYWRIGHT_TOKEN_ENV];
if (typeof direct === 'string' && direct) return direct;
const opencode = parsed?.mcp?.[PLAYWRIGHT_SERVER_NAME]?.environment?.[PLAYWRIGHT_TOKEN_ENV];
if (typeof opencode === 'string' && opencode) return opencode;
return null;
}
export function upsertJsonConfigToken(content: string, token: string, filePath?: string): string {
const parsed = content.trim() ? JSON.parse(content) : {};
// Determine format: use OpenCode format only if explicitly an opencode config,
// or if the existing content already uses `mcp` key (not `mcpServers`)
const useOpenCodeFormat = filePath
? isOpenCodeConfig(filePath)
: (!parsed.mcpServers && parsed.mcp);
if (useOpenCodeFormat) {
parsed.mcp = parsed.mcp ?? {};
parsed.mcp[PLAYWRIGHT_SERVER_NAME] = parsed.mcp[PLAYWRIGHT_SERVER_NAME] ?? {
command: ['npx', '-y', '@playwright/mcp@latest', '--extension'],
enabled: true,
type: 'local',
};
parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment = parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment ?? {};
parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment[PLAYWRIGHT_TOKEN_ENV] = token;
} else {
parsed.mcpServers = parsed.mcpServers ?? {};
parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] ?? {
command: 'npx',
args: ['-y', '@playwright/mcp@latest', '--extension'],
};
parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env ?? {};
parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env[PLAYWRIGHT_TOKEN_ENV] = token;
}
return `${JSON.stringify(parsed, null, 2)}\n`;
}
export function readTomlConfigToken(content: string): string | null {
const sectionMatch = content.match(/\[mcp_servers\.playwright\.env\][\s\S]*?(?=\n\[|$)/);
if (!sectionMatch) return null;
const tokenMatch = sectionMatch[0].match(/^\s*PLAYWRIGHT_MCP_EXTENSION_TOKEN\s*=\s*"([^"\n]+)"/m);
return tokenMatch?.[1] ?? null;
}
export function upsertTomlConfigToken(content: string, token: string): string {
const envSectionRe = /(\[mcp_servers\.playwright\.env\][\s\S]*?)(?=\n\[|$)/;
const tokenLine = `PLAYWRIGHT_MCP_EXTENSION_TOKEN = "${token}"`;
if (envSectionRe.test(content)) {
return content.replace(envSectionRe, (section) => {
if (/^\s*PLAYWRIGHT_MCP_EXTENSION_TOKEN\s*=/m.test(section)) {
return section.replace(/^\s*PLAYWRIGHT_MCP_EXTENSION_TOKEN\s*=.*$/m, tokenLine);
}
return `${section.replace(/\s*$/, '')}\n${tokenLine}\n`;
});
}
const baseSectionRe = /(\[mcp_servers\.playwright\][\s\S]*?)(?=\n\[|$)/;
if (baseSectionRe.test(content)) {
return content.replace(baseSectionRe, (section) => `${section.replace(/\s*$/, '')}\n\n[mcp_servers.playwright.env]\n${tokenLine}\n`);
}
const prefix = content.trim() ? `${content.replace(/\s*$/, '')}\n\n` : '';
return `${prefix}[mcp_servers.playwright]\ntype = "stdio"\ncommand = "npx"\nargs = ["-y", "@playwright/mcp@latest", "--extension"]\n\n[mcp_servers.playwright.env]\n${tokenLine}\n`;
}
export function fileExists(filePath: string): boolean {
try {
@@ -261,17 +166,17 @@ function canWrite(filePath: string): boolean {
function readConfigStatus(filePath: string): McpConfigStatus {
const format: McpConfigFormat = filePath.endsWith('.toml') ? 'toml' : 'json';
if (!fileExists(filePath)) {
return { path: filePath, exists: false, format, token: null, fingerprint: null, writable: canWrite(filePath) };
return { path: filePath, exists: false, format, token: null, writable: canWrite(filePath) };
}
try {
const content = fs.readFileSync(filePath, 'utf-8');
const token = format === 'toml' ? readTomlConfigToken(content) : readJsonConfigToken(content);
// Deprecated token extraction.
const token = null;
return {
path: filePath,
exists: true,
format,
token,
fingerprint: getTokenFingerprint(token ?? undefined),
writable: canWrite(filePath),
};
} catch (error: any) {
@@ -280,7 +185,6 @@ function readConfigStatus(filePath: string): McpConfigStatus {
exists: true,
format,
token: null,
fingerprint: null,
writable: canWrite(filePath),
parseError: error?.message ?? String(error),
};
@@ -518,10 +422,10 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
const envToken = process.env[PLAYWRIGHT_TOKEN_ENV] ?? null;
const shellPath = opts.shellRc ?? getDefaultShellRcPath();
const shellFiles: ShellFileStatus[] = [shellPath].map((filePath) => {
if (!fileExists(filePath)) return { path: filePath, exists: false, token: null, fingerprint: null };
if (!fileExists(filePath)) return { path: filePath, exists: false, token: null };
const content = fs.readFileSync(filePath, 'utf-8');
const token = readTokenFromShellContent(content);
return { path: filePath, exists: true, token, fingerprint: getTokenFingerprint(token ?? undefined) };
return { path: filePath, exists: true, token };
});
const configPaths = opts.configPaths?.length ? opts.configPaths : getDefaultMcpConfigPaths();
const configs = configPaths.map(readConfigStatus);
@@ -551,44 +455,29 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
const report: DoctorReport = {
cliVersion: opts.cliVersion,
envToken,
envFingerprint: getTokenFingerprint(envToken ?? undefined),
extensionToken,
extensionFingerprint: getTokenFingerprint(extensionToken ?? undefined),
extensionInstalled: extInstall.installed,
extensionBrowsers: extInstall.browsers,
shellFiles,
configs,
recommendedToken,
recommendedFingerprint: getTokenFingerprint(recommendedToken ?? undefined),
connectivity,
warnings: [],
issues: [],
};
if (!extInstall.installed) report.issues.push('Playwright MCP Bridge extension is not installed in any browser.');
if (!envToken) report.issues.push(`Current environment is missing ${PLAYWRIGHT_TOKEN_ENV}.`);
if (!shellFiles.some(s => s.token)) report.issues.push('Shell startup file does not export PLAYWRIGHT_MCP_EXTENSION_TOKEN.');
if (!configs.some(c => c.token)) report.issues.push('No scanned MCP config currently contains a Playwright extension token.');
if (uniqueTokens.length > 1) report.issues.push('Detected inconsistent Playwright MCP tokens across env/config files.');
if (!extInstall.installed) report.issues.push('OpenCLI MCP Bridge extension is not installed in any browser.');
if (connectivity && !connectivity.ok) report.issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
for (const config of configs) {
if (config.parseError) report.warnings.push(`Could not parse ${config.path}: ${config.parseError}`);
}
if (!recommendedToken) {
report.warnings.push('No token source found.');
//
}
return report;
}
export function renderBrowserDoctorReport(report: DoctorReport): string {
const tokenFingerprints = [
report.extensionFingerprint,
report.envFingerprint,
...report.shellFiles.map(shell => shell.fingerprint),
...report.configs.filter(config => config.exists).map(config => config.fingerprint),
].filter((value): value is string => !!value);
const uniqueFingerprints = [...new Set(tokenFingerprints)];
const hasMismatch = uniqueFingerprints.length > 1;
const lines = [chalk.bold(`opencli v${report.cliVersion ?? 'unknown'} doctor`), ''];
const installStatus: ReportStatus = report.extensionInstalled ? 'OK' : 'MISSING';
@@ -597,17 +486,17 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
: 'Extension not installed in any browser';
lines.push(statusLine(installStatus, installDetail));
const extStatus: ReportStatus = !report.extensionToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
lines.push(statusLine(extStatus, `Extension token (Chrome LevelDB): ${tokenSummary(report.extensionToken, report.extensionFingerprint)}`));
const extStatus: ReportStatus = 'OK';
lines.push(statusLine(extStatus, `Extension token (Chrome LevelDB): ${tokenSummary(report.extensionToken)}`));
const envStatus: ReportStatus = !report.envToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
lines.push(statusLine(envStatus, `Environment token: ${tokenSummary(report.envToken, report.envFingerprint)}`));
const envStatus: ReportStatus = 'OK';
lines.push(statusLine(envStatus, `Environment token: ${tokenSummary(report.envToken)}`));
for (const shell of report.shellFiles) {
const shellStatus: ReportStatus = !shell.token ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
const shellStatus: ReportStatus = 'OK';
const tool = toolName(shell.path);
const suffix = tool ? chalk.dim(` [${tool}]`) : '';
lines.push(statusLine(shellStatus, `${shortenPath(shell.path)}${suffix}: ${tokenSummary(shell.token, shell.fingerprint)}`));
lines.push(statusLine(shellStatus, `${shortenPath(shell.path)}${suffix}: ${tokenSummary(shell.token)}`));
}
const existingConfigs = report.configs.filter(config => config.exists);
const missingConfigCount = report.configs.length - existingConfigs.length;
@@ -618,12 +507,10 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
? 'WARN'
: !config.token
? 'MISSING'
: hasMismatch
? 'MISMATCH'
: 'OK';
: 'OK';
const tool = toolName(config.path);
const suffix = tool ? chalk.dim(` [${tool}]`) : '';
lines.push(statusLine(configStatus, `${shortenPath(config.path)}${suffix}: ${tokenSummary(config.token, config.fingerprint)}${parseSuffix}`));
lines.push(statusLine(configStatus, `${shortenPath(config.path)}${suffix}: ${tokenSummary(config.token)}${parseSuffix}`));
}
} else {
lines.push(statusLine('MISSING', 'MCP config: no existing config files found'));
@@ -643,8 +530,8 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
}
lines.push(statusLine(
hasMismatch ? 'MISMATCH' : report.recommendedToken ? 'OK' : 'WARN',
`Recommended token fingerprint: ${report.recommendedFingerprint ?? 'unavailable'}`,
'OK',
`Token Configuration: Not required for OpenCLI MCP`,
));
if (report.issues.length) {
lines.push('', chalk.yellow('Issues:'));
@@ -673,48 +560,6 @@ export function writeFileWithMkdir(filePath: string, content: string): void {
}
export async function applyBrowserDoctorFix(report: DoctorReport, opts: DoctorOptions = {}): Promise<string[]> {
const token = opts.token ?? report.recommendedToken;
if (!token) throw new Error('No Playwright MCP token is available to write. Provide --token first.');
const fp = getTokenFingerprint(token);
const plannedWrites: string[] = [];
const shellPath = opts.shellRc ?? report.shellFiles[0]?.path ?? getDefaultShellRcPath();
const shellStatus = report.shellFiles.find(s => s.path === shellPath);
if (shellStatus?.fingerprint !== fp) plannedWrites.push(shellPath);
for (const config of report.configs) {
if (!config.writable) continue;
if (config.fingerprint === fp) continue; // already correct
plannedWrites.push(config.path);
}
if (plannedWrites.length === 0) {
console.log(chalk.green('All config files are already up to date.'));
return [];
}
if (!opts.yes) {
const ok = await confirmPrompt(`Update ${plannedWrites.length} file(s) with Playwright MCP token fingerprint ${fp}?`);
if (!ok) return [];
}
const written: string[] = [];
if (plannedWrites.includes(shellPath)) {
const shellBefore = fileExists(shellPath) ? fs.readFileSync(shellPath, 'utf-8') : '';
writeFileWithMkdir(shellPath, upsertShellToken(shellBefore, token, shellPath));
written.push(shellPath);
}
for (const config of report.configs) {
if (!plannedWrites.includes(config.path)) continue;
if (config.parseError) continue;
const before = fileExists(config.path) ? fs.readFileSync(config.path, 'utf-8') : '';
const next = config.format === 'toml'
? upsertTomlConfigToken(before, token)
: upsertJsonConfigToken(before, token, config.path);
writeFileWithMkdir(config.path, next);
written.push(config.path);
}
process.env[PLAYWRIGHT_TOKEN_ENV] = token;
return written;
console.log(chalk.green('OpenCLI MCP Bridge does not require token configuration!'));
return [];
}
+12 -181
View File
@@ -1,205 +1,36 @@
/**
* setup.ts — Interactive Playwright MCP token setup
* setup.ts — OpenCLI MCP token setup
*
* Discovers the extension token, shows an interactive checkbox
* for selecting which config files to update, and applies changes.
* OpenCLI MCP is now tokenless. This file simply informs the user
* that token configuration is no longer required.
*/
import * as fs from 'node:fs';
import chalk from 'chalk';
import { createInterface } from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';
import {
type DoctorReport,
PLAYWRIGHT_TOKEN_ENV,
checkExtensionInstalled,
checkTokenConnectivity,
discoverExtensionToken,
fileExists,
getDefaultShellRcPath,
runBrowserDoctor,
shortenPath,
toolName,
upsertJsonConfigToken,
upsertShellToken,
upsertTomlConfigToken,
writeFileWithMkdir,
} from './doctor.js';
import { getTokenFingerprint } from './browser/index.js';
import { type CheckboxItem, checkboxPrompt } from './tui.js';
import { checkTokenConnectivity } from './doctor.js';
export async function runSetup(opts: { cliVersion?: string; token?: string } = {}) {
console.log();
console.log(chalk.bold(' opencli setup') + chalk.dim(' — Playwright MCP token configuration'));
console.log(chalk.bold(' opencli setup') + chalk.dim(' — OpenCLI MCP configuration'));
console.log();
console.log(` ${chalk.green('✓')} Configuration complete.`);
console.log(` ${chalk.dim('OpenCLI MCP Bridge no longer requires token configuration.')}`);
console.log();
// Step 1: Discover token
let token = opts.token ?? null;
if (!token) {
const extensionToken = discoverExtensionToken();
const envToken = process.env[PLAYWRIGHT_TOKEN_ENV] ?? null;
if (extensionToken && envToken && extensionToken === envToken) {
token = extensionToken;
console.log(` ${chalk.green('✓')} Token auto-discovered from Chrome extension`);
console.log(` Fingerprint: ${chalk.bold(getTokenFingerprint(token) ?? 'unknown')}`);
} else if (extensionToken) {
token = extensionToken;
console.log(` ${chalk.green('✓')} Token discovered from Chrome extension ` +
chalk.dim(`(${getTokenFingerprint(token)})`));
if (envToken && envToken !== extensionToken) {
console.log(` ${chalk.yellow('!')} Environment has different token ` +
chalk.dim(`(${getTokenFingerprint(envToken)})`));
}
} else if (envToken) {
token = envToken;
console.log(` ${chalk.green('✓')} Token from environment variable ` +
chalk.dim(`(${getTokenFingerprint(token)})`));
}
} else {
console.log(` ${chalk.green('✓')} Using provided token ` +
chalk.dim(`(${getTokenFingerprint(token)})`));
}
if (!token) {
// Give precise diagnosis of why token scan failed
const extInstall = checkExtensionInstalled();
console.log(` ${chalk.red('✗')} Browser token scan failed\n`);
if (!extInstall.installed) {
console.log(chalk.dim(' Cause: Playwright MCP Bridge extension is not installed'));
console.log(chalk.dim(' Fix: Install from https://chromewebstore.google.com/detail/'));
console.log(chalk.dim(' playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm'));
} else {
console.log(chalk.dim(` Cause: Extension is installed (${extInstall.browsers.join(', ')}) but token not found in LevelDB`));
console.log(chalk.dim(' Fix: 1) Open the extension popup and verify the token is generated'));
console.log(chalk.dim(' 2) Close Chrome completely, then re-run setup'));
}
console.log();
console.log(` You can enter the token manually, or fix the above and re-run ${chalk.bold('opencli setup')}.`);
console.log();
const rl = createInterface({ input, output });
const answer = await rl.question(' Token (press Enter to abort): ');
rl.close();
token = answer.trim();
if (!token) {
console.log(chalk.red('\n No token provided. Aborting.\n'));
return;
}
}
const fingerprint = getTokenFingerprint(token) ?? 'unknown';
console.log();
// Step 2: Scan all config locations
const report = await runBrowserDoctor({ token, cliVersion: opts.cliVersion });
// Step 3: Build checkbox items
const items: CheckboxItem[] = [];
// Shell file
const shellPath = report.shellFiles[0]?.path ?? getDefaultShellRcPath();
const shellStatus = report.shellFiles[0];
const shellFp = shellStatus?.fingerprint;
const shellOk = shellFp === fingerprint;
const shellTool = toolName(shellPath) || 'Shell';
items.push({
label: padRight(shortenPath(shellPath), 50) + chalk.dim(` [${shellTool}]`),
value: `shell:${shellPath}`,
checked: !shellOk,
status: shellOk ? `configured (${shellFp})` : shellFp ? `mismatch (${shellFp})` : 'missing',
statusColor: shellOk ? 'green' : shellFp ? 'yellow' : 'red',
});
// Config files
for (const config of report.configs) {
const fp = config.fingerprint;
const ok = fp === fingerprint;
const tool = toolName(config.path);
items.push({
label: padRight(shortenPath(config.path), 50) + chalk.dim(tool ? ` [${tool}]` : ''),
value: `config:${config.path}`,
checked: false, // let user explicitly select which tools to configure
status: ok ? `configured (${fp})` : !config.exists ? 'will create' : fp ? `mismatch (${fp})` : 'missing',
statusColor: ok ? 'green' : 'yellow',
});
}
// Step 4: Show interactive checkbox
console.clear();
const selected = await checkboxPrompt(items, {
title: ` ${chalk.bold('opencli setup')} — token ${chalk.cyan(fingerprint)}`,
});
if (selected.length === 0) {
console.log(chalk.dim(' No changes made.\n'));
return;
}
// Step 5: Apply changes
const written: string[] = [];
let wroteShell = false;
for (const sel of selected) {
if (sel.startsWith('shell:')) {
const p = sel.slice('shell:'.length);
const before = fileExists(p) ? fs.readFileSync(p, 'utf-8') : '';
writeFileWithMkdir(p, upsertShellToken(before, token, p));
written.push(p);
wroteShell = true;
} else if (sel.startsWith('config:')) {
const p = sel.slice('config:'.length);
const config = report.configs.find(c => c.path === p);
if (config && config.parseError) continue;
const before = fileExists(p) ? fs.readFileSync(p, 'utf-8') : '';
const format = config?.format ?? (p.endsWith('.toml') ? 'toml' : 'json');
const next = format === 'toml' ? upsertTomlConfigToken(before, token) : upsertJsonConfigToken(before, token, p);
writeFileWithMkdir(p, next);
written.push(p);
}
}
process.env[PLAYWRIGHT_TOKEN_ENV] = token;
// Step 6: Summary
if (written.length > 0) {
console.log(chalk.green.bold(` ✓ Updated ${written.length} file(s):`));
for (const p of written) {
const tool = toolName(p);
console.log(` ${chalk.dim('•')} ${shortenPath(p)}${tool ? chalk.dim(` [${tool}]`) : ''}`);
}
if (wroteShell) {
console.log();
console.log(chalk.cyan(` 💡 Run ${chalk.bold(`source ${shortenPath(shellPath)}`)} to apply token to current shell.`));
}
} else {
console.log(chalk.yellow(' No files were changed.'));
}
console.log();
// Step 7: Auto-verify browser connectivity
// Auto-verify browser connectivity
console.log(chalk.dim(' Verifying browser connectivity...'));
try {
const result = await checkTokenConnectivity({ timeout: 5 });
if (result.ok) {
console.log(` ${chalk.green('✓')} Browser connected in ${(result.durationMs / 1000).toFixed(1)}s`);
} else {
console.log(` ${chalk.green('✓')} Token saved successfully.`);
console.log(` ${chalk.yellow('!')} Browser connectivity test failed: ${result.error ?? 'unknown'}`);
console.log(chalk.dim(' Token configuration is complete. To use opencli, make sure Chrome'));
console.log(chalk.dim(' is running with the Playwright MCP Bridge extension enabled.'));
console.log(chalk.dim(' To use opencli, make sure Chrome is running with Developer Mode'));
console.log(chalk.dim(' and the OpenCLI MCP Bridge extension is enabled.'));
console.log(chalk.dim(` Run ${chalk.bold('opencli doctor --live')} to re-test connectivity.`));
}
} catch {
console.log(` ${chalk.green('✓')} Token saved successfully.`);
console.log(` ${chalk.yellow('!')} Browser connectivity test skipped (Chrome may not be running).`);
console.log(chalk.dim(' Token configuration is complete. Start Chrome to begin using opencli.'));
console.log(chalk.dim(' Start Chrome to begin using opencli.'));
console.log(chalk.dim(` Run ${chalk.bold('opencli doctor --live')} to re-test connectivity.`));
}
console.log();
}
function padRight(s: string, n: number): string {
const visible = s.replace(/\x1b\[[0-9;]*m/g, '');
return visible.length >= n ? s : s + ' '.repeat(n - visible.length);
}
+2
View File
@@ -7,6 +7,7 @@ export default defineConfig({
test: {
name: 'unit',
include: ['src/**/*.test.ts'],
sequence: { groupOrder: 1 },
},
},
{
@@ -14,6 +15,7 @@ export default defineConfig({
name: 'e2e',
include: ['tests/**/*.test.ts'],
maxWorkers: 2,
sequence: { groupOrder: 2 },
},
},
],