Compare commits
3 Commits
main
...
self-extension
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f998e4ff0 | |||
| 36b06e9f32 | |||
| f0273f94bb |
Generated
+25
-65
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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';
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user