From 981c167a0b66bc3c7b0fc1ad9ffa288629c0800a Mon Sep 17 00:00:00 2001 From: jackwener Date: Tue, 17 Mar 2026 14:38:16 +0800 Subject: [PATCH] feat(doctor): add extension install check and token connectivity test - checkExtensionInstalled(): scans Chrome/Edge/Chromium Extensions dirs - checkTokenConnectivity(): actual MCP handshake via --live flag - Updated DoctorReport type and report rendering - Added unit tests for new rendering (12/12 pass) --- src/doctor.test.ts | 45 +++++++++++++++++++ src/doctor.ts | 105 +++++++++++++++++++++++++++++++++++++++++++++ src/main.ts | 3 +- 3 files changed, 152 insertions(+), 1 deletion(-) diff --git a/src/doctor.test.ts b/src/doctor.test.ts index ef1cb7e..de57ba3 100644 --- a/src/doctor.test.ts +++ b/src/doctor.test.ts @@ -94,6 +94,8 @@ describe('doctor report rendering', () => { 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 }], recommendedToken: 'abc123', @@ -102,6 +104,7 @@ describe('doctor report rendering', () => { issues: [], })); + expect(text).toContain('[OK] Extension installed (Chrome)'); expect(text).toContain('[OK] Environment token: configured (fp1)'); expect(text).toContain('[OK] /tmp/mcp.json'); expect(text).toContain('configured (fp1)'); @@ -113,6 +116,8 @@ describe('doctor report rendering', () => { 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 }], recommendedToken: 'abc123', @@ -121,10 +126,50 @@ describe('doctor report rendering', () => { issues: ['Detected inconsistent Playwright MCP tokens across env/config files.'], })); + 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'); }); + + 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: [], + })); + + expect(text).toContain('[OK] Browser connectivity: connected in 1.2s'); + }); + + 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: [], + })); + + expect(text).toContain('[WARN] Browser connectivity: not tested (use --live)'); + }); }); diff --git a/src/doctor.ts b/src/doctor.ts index c25607c..c11a469 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -16,6 +16,7 @@ const TOKEN_LINE_RE = /^(\s*export\s+PLAYWRIGHT_MCP_EXTENSION_TOKEN=)(['"]?)([^' export type DoctorOptions = { fix?: boolean; yes?: boolean; + live?: boolean; shellRc?: string; configPaths?: string[]; token?: string; @@ -41,16 +42,25 @@ export type McpConfigStatus = { parseError?: string; }; +export type ConnectivityResult = { + ok: boolean; + error?: string; + durationMs: number; +}; + 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[]; }; @@ -393,6 +403,69 @@ function validateBase64urlToken(token: string): boolean { } +/** + * Check whether the Playwright MCP Bridge extension is installed in any browser. + * Scans Chrome/Chromium/Edge Extensions directories for the known extension ID. + */ +export function checkExtensionInstalled(): { installed: boolean; browsers: string[] } { + const home = os.homedir(); + const platform = os.platform(); + const browserDirs: Array<{ name: string; base: string }> = []; + + if (platform === 'darwin') { + browserDirs.push( + { name: 'Chrome', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome') }, + { name: 'Chrome Canary', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary') }, + { name: 'Chromium', base: path.join(home, 'Library', 'Application Support', 'Chromium') }, + { name: 'Edge', base: path.join(home, 'Library', 'Application Support', 'Microsoft Edge') }, + ); + } else if (platform === 'linux') { + browserDirs.push( + { name: 'Chrome', base: path.join(home, '.config', 'google-chrome') }, + { name: 'Chromium', base: path.join(home, '.config', 'chromium') }, + { name: 'Edge', base: path.join(home, '.config', 'microsoft-edge') }, + ); + } else if (platform === 'win32') { + const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'); + browserDirs.push( + { name: 'Chrome', base: path.join(appData, 'Google', 'Chrome', 'User Data') }, + { name: 'Edge', base: path.join(appData, 'Microsoft', 'Edge', 'User Data') }, + ); + } + + const profiles = ['Default', 'Profile 1', 'Profile 2', 'Profile 3']; + const foundBrowsers: string[] = []; + + for (const { name, base } of browserDirs) { + for (const profile of profiles) { + const extDir = path.join(base, profile, 'Extensions', PLAYWRIGHT_EXTENSION_ID); + if (fileExists(extDir)) { + foundBrowsers.push(name); + break; // one match per browser is enough + } + } + } + + return { installed: foundBrowsers.length > 0, browsers: [...new Set(foundBrowsers)] }; +} + +/** + * Test token connectivity by attempting a real MCP connection. + * Connects, does the JSON-RPC handshake, and immediately closes. + */ +export async function checkTokenConnectivity(opts?: { timeout?: number }): Promise { + const timeout = opts?.timeout ?? 8; + const start = Date.now(); + try { + const mcp = new PlaywrightMCP(); + await mcp.connect({ timeout }); + await mcp.close(); + return { ok: true, durationMs: Date.now() - start }; + } catch (err: any) { + return { ok: false, error: err?.message ?? String(err), durationMs: Date.now() - start }; + } +} + export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise { const envToken = process.env[PLAYWRIGHT_TOKEN_ENV] ?? null; const shellPath = opts.shellRc ?? getDefaultShellRcPath(); @@ -418,24 +491,38 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise 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 (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}`); } @@ -456,6 +543,12 @@ export function renderBrowserDoctorReport(report: DoctorReport): string { const hasMismatch = uniqueFingerprints.length > 1; const lines = [chalk.bold(`opencli v${report.cliVersion ?? 'unknown'} doctor`), '']; + const installStatus: ReportStatus = report.extensionInstalled ? 'OK' : 'MISSING'; + const installDetail = report.extensionInstalled + ? `Extension installed (${report.extensionBrowsers.join(', ')})` + : '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)}`)); @@ -489,6 +582,18 @@ export function renderBrowserDoctorReport(report: DoctorReport): string { } if (missingConfigCount > 0) lines.push(chalk.dim(` Other scanned config locations not present: ${missingConfigCount}`)); lines.push(''); + + // Connectivity result + if (report.connectivity) { + const connStatus: ReportStatus = report.connectivity.ok ? 'OK' : 'WARN'; + const connDetail = report.connectivity.ok + ? `Browser connectivity: connected in ${(report.connectivity.durationMs / 1000).toFixed(1)}s` + : `Browser connectivity: failed (${report.connectivity.error ?? 'unknown'})`; + lines.push(statusLine(connStatus, connDetail)); + } else { + lines.push(statusLine('WARN', 'Browser connectivity: not tested (use --live)')); + } + lines.push(statusLine( hasMismatch ? 'MISMATCH' : report.recommendedToken ? 'OK' : 'WARN', `Recommended token fingerprint: ${report.recommendedFingerprint ?? 'unavailable'}`, diff --git a/src/main.ts b/src/main.ts index 271471e..cdf71dc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -100,12 +100,13 @@ program.command('doctor') .option('--fix', 'Apply suggested fixes to shell rc and detected MCP configs', false) .option('-y, --yes', 'Skip confirmation prompts when applying fixes', false) .option('--token ', 'Override token to write instead of auto-detecting') + .option('--live', 'Test browser connectivity (requires Chrome running)', false) .option('--shell-rc ', 'Shell startup file to update') .option('--mcp-config ', 'Comma-separated MCP config paths to scan/update') .action(async (opts) => { const { runBrowserDoctor, renderBrowserDoctorReport, applyBrowserDoctorFix } = await import('./doctor.js'); const configPaths = opts.mcpConfig ? String(opts.mcpConfig).split(',').map((s: string) => s.trim()).filter(Boolean) : undefined; - const report = await runBrowserDoctor({ token: opts.token, shellRc: opts.shellRc, configPaths, cliVersion: PKG_VERSION }); + const report = await runBrowserDoctor({ token: opts.token, live: opts.live, shellRc: opts.shellRc, configPaths, cliVersion: PKG_VERSION }); console.log(renderBrowserDoctorReport(report)); if (opts.fix) { const written = await applyBrowserDoctorFix(report, { fix: true, yes: opts.yes, token: opts.token, shellRc: opts.shellRc, configPaths });