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)
This commit is contained in:
jackwener committed 2026-03-17 14:38:16 +08:00
1 parent 2463689105
commit 981c167a0b
3 files changed
+152 -1

No files matched your search

+45
View File
@@ -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)');
});
});
+105
View File
@@ -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<ConnectivityResult> {
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<DoctorReport> {
const envToken = process.env[PLAYWRIGHT_TOKEN_ENV] ?? null;
const shellPath = opts.shellRc ?? getDefaultShellRcPath();
@@ -418,24 +491,38 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
const uniqueTokens = [...new Set(allTokens)];
const recommendedToken = opts.token ?? extensionToken ?? envToken ?? (uniqueTokens.length === 1 ? uniqueTokens[0] : null) ?? null;
// Check extension installation
const extInstall = checkExtensionInstalled();
// Connectivity test (only when --live)
let connectivity: ConnectivityResult | undefined;
if (opts.live) {
connectivity = await checkTokenConnectivity();
}
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 (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'}`,
+2 -1
View File
@@ -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 <token>', 'Override token to write instead of auto-detecting')
.option('--live', 'Test browser connectivity (requires Chrome running)', false)
.option('--shell-rc <path>', 'Shell startup file to update')
.option('--mcp-config <paths>', '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 });