From 9048146caab3822353bab6c8497e814e1c29b999 Mon Sep 17 00:00:00 2001 From: ray cui Date: Thu, 30 Apr 2026 23:13:10 +0800 Subject: [PATCH] Improve browser bridge connection errors --- src/browser.test.ts | 6 ++- src/browser/bridge.ts | 6 ++- src/doctor.test.ts | 4 +- src/doctor.ts | 3 +- src/errors.test.ts | 62 ++++++++++++++++++++++++++++++ src/errors.ts | 89 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 165 insertions(+), 5 deletions(-) diff --git a/src/browser.test.ts b/src/browser.test.ts index 1753195c2..8e109febc 100644 --- a/src/browser.test.ts +++ b/src/browser.test.ts @@ -162,7 +162,11 @@ describe('BrowserBridge state', () => { const bridge = new BrowserBridge(); - await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Browser Bridge extension not connected'); + await expect(bridge.connect({ timeout: 0.1 })).rejects.toMatchObject({ + message: 'Browser Bridge extension not connected', + kind: 'extension-not-connected', + hint: expect.stringContaining('Try running the command again'), + }); }); it('attempts stale daemon replacement when daemonVersion is missing', async () => { diff --git a/src/browser/bridge.ts b/src/browser/bridge.ts index d9b344729..427faa261 100644 --- a/src/browser/bridge.ts +++ b/src/browser/bridge.ts @@ -139,7 +139,8 @@ export class BrowserBridge implements IBrowserFactory { throw new BrowserConnectError( 'Browser Bridge extension not connected', 'Make sure Chrome/Chromium is open and the extension is enabled.\n' + - 'If the extension is installed, try: opencli daemon stop && opencli doctor\n' + + 'Try running the command again — the extension may need a moment to reconnect.\n' + + 'If it still fails, reload OpenCLI in chrome://extensions and run: opencli doctor\n' + 'If not installed:\n' + ' 1. Download: https://github.com/jackwener/opencli/releases\n' + ' 2. Open chrome://extensions → Developer Mode → Load unpacked', @@ -179,7 +180,8 @@ export class BrowserBridge implements IBrowserFactory { throw new BrowserConnectError( 'Browser Bridge extension not connected', 'Make sure Chrome/Chromium is open and the extension is enabled.\n' + - 'If the extension is installed, try: opencli daemon stop && opencli doctor\n' + + 'Try running the command again — the extension may need a moment to reconnect.\n' + + 'If it still fails, reload OpenCLI in chrome://extensions and run: opencli doctor\n' + 'If not installed:\n' + ' 1. Download: https://github.com/jackwener/opencli/releases\n' + ' 2. Open chrome://extensions → Developer Mode → Load unpacked', diff --git a/src/doctor.test.ts b/src/doctor.test.ts index 69d593413..fdeec2cd4 100644 --- a/src/doctor.test.ts +++ b/src/doctor.test.ts @@ -87,11 +87,13 @@ describe('doctor report rendering', () => { const text = strip(renderBrowserDoctorReport({ daemonRunning: true, extensionConnected: false, - issues: ['Daemon is running but the Chrome extension is not connected.'], + issues: ['Daemon is running but the Chrome extension is not connected.\nTry running the command again — the extension may need a moment to reconnect.\nIf it still fails, reload OpenCLI in chrome://extensions and run: opencli daemon restart'], })); expect(text).toContain('[OK] Daemon: running on port 19825'); expect(text).toContain('[MISSING] Extension: not connected'); + expect(text).toContain('Try running the command again'); + expect(text).toContain('reload OpenCLI in chrome://extensions'); }); it('renders a warning when the extension version is unknown', () => { diff --git a/src/doctor.ts b/src/doctor.ts index e4e6d19c6..e1b26435d 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -173,7 +173,8 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise { @@ -77,6 +78,45 @@ describe('Error type hierarchy', () => { const err = new BrowserConnectError('Cannot connect'); expect(err.code).toBe('BROWSER_CONNECT'); }); + + it('BrowserConnectError envelope includes machine-readable layer and recovery advice', () => { + const err = new BrowserConnectError( + 'Browser Bridge extension not connected', + 'Reload the extension and retry', + 'extension-not-connected', + ); + const envelope = toEnvelope(err); + + expect(envelope.error).toMatchObject({ + code: 'BROWSER_CONNECT', + kind: 'extension-not-connected', + layer: 'browser_bridge', + recovery: { + safe: ['retry the command once'], + manual: expect.arrayContaining([ + 'reload the OpenCLI extension in chrome://extensions if it still fails', + 'run opencli doctor', + ]), + }, + }); + }); + + it('BrowserConnectError envelope maps profile errors to the browser bridge layer', () => { + const envelope = toEnvelope(new BrowserConnectError( + 'Multiple Browser Bridge profiles are connected', + undefined, + 'profile-required', + )); + + expect(envelope.error).toMatchObject({ + code: 'BROWSER_CONNECT', + kind: 'profile-required', + layer: 'browser_bridge', + recovery: { + safe: expect.arrayContaining(['run opencli profile list']), + }, + }); + }); }); describe('toEnvelope', () => { @@ -101,6 +141,28 @@ describe('toEnvelope', () => { expect(envelope.error).not.toHaveProperty('help'); }); + it('preserves trace metadata on BrowserConnectError envelopes', () => { + const err = new BrowserConnectError('Browser Bridge extension not connected'); + attachTraceReceipt(err, { + schemaVersion: 1, + opencliVersion: 'test', + traceId: 'trace-1', + traceDir: '/tmp/trace-1', + summaryPath: '/tmp/trace-1/summary.md', + receiptPath: '/tmp/trace-1/receipt.json', + status: 'failure', + createdAt: '2026-05-03T00:00:00.000Z', + }); + + expect(toEnvelope(err).trace).toEqual({ + traceId: 'trace-1', + dir: '/tmp/trace-1', + summaryPath: '/tmp/trace-1/summary.md', + receiptPath: '/tmp/trace-1/receipt.json', + status: 'failure', + }); + }); + it('converts unknown Error to UNKNOWN envelope', () => { const envelope = toEnvelope(new Error('random failure')); expect(envelope).toEqual({ diff --git a/src/errors.ts b/src/errors.ts index 7a536e18a..c50ac0dc7 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -89,6 +89,76 @@ export class BrowserConnectError extends CliError { } } +type BrowserConnectLayer = 'daemon' | 'browser_bridge' | 'browser_command' | 'unknown'; + +type BrowserConnectRecovery = { + safe: string[]; + manual: string[]; +}; + +function browserConnectLayer(kind: BrowserConnectKind): BrowserConnectLayer { + switch (kind) { + case 'daemon-not-running': + return 'daemon'; + case 'extension-not-connected': + case 'profile-required': + case 'profile-disconnected': + return 'browser_bridge'; + case 'command-failed': + return 'browser_command'; + default: + return 'unknown'; + } +} + +function browserConnectRecovery(kind: BrowserConnectKind): BrowserConnectRecovery { + switch (kind) { + case 'daemon-not-running': + return { + safe: ['run opencli doctor'], + manual: [ + 'make sure the daemon port is available', + 'run opencli daemon stop && opencli doctor if a stale daemon is suspected', + ], + }; + case 'extension-not-connected': + return { + safe: ['retry the command once'], + manual: [ + 'reload the OpenCLI extension in chrome://extensions if it still fails', + 'run opencli doctor', + 'run opencli daemon stop && opencli doctor if the bridge still does not reconnect', + ], + }; + case 'profile-required': + return { + safe: [ + 'run opencli profile list', + 'select a profile with opencli profile use or pass --profile ', + ], + manual: ['disconnect unused Chrome profiles if profile selection is not intended'], + }; + case 'profile-disconnected': + return { + safe: [ + 'open the selected Chrome profile', + 'make sure the OpenCLI extension is enabled in that profile', + ], + manual: ['choose another connected profile with opencli profile use '], + }; + case 'command-failed': + return { + safe: ['retry the command once'], + manual: ['run opencli doctor if browser commands keep failing'], + }; + default: + return { + safe: ['run opencli doctor'], + manual: [], + }; + } +} + export class CommandExecutionError extends CliError { constructor(message: string, hint?: string) { super('COMMAND_EXEC', message, hint, EXIT_CODES.GENERIC_ERROR); @@ -170,6 +240,9 @@ export interface ErrorEnvelope { code: string; message: string; help?: string; + kind?: string; + layer?: string; + recovery?: BrowserConnectRecovery; exitCode: number; stack?: string; cause?: string; @@ -212,6 +285,22 @@ export function toEnvelope(err: unknown): ErrorEnvelope { receiptPath: traceReceipt.receiptPath, status: traceReceipt.status, } : undefined; + if (err instanceof BrowserConnectError) { + return { + ok: false, + error: { + code: err.code, + message: err.message, + ...(err.hint ? { help: err.hint } : {}), + kind: err.kind, + layer: browserConnectLayer(err.kind), + recovery: browserConnectRecovery(err.kind), + exitCode: err.exitCode, + ...(cause ? { cause } : {}), + }, + ...(trace ? { trace } : {}), + }; + } if (err instanceof CliError) { return { ok: false,