Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
6 changes: 4 additions & 2 deletions src/browser/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
4 changes: 3 additions & 1 deletion src/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
3 changes: 2 additions & 1 deletion src/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,8 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
} else {
issues.push(
'Daemon is running but the Chrome/Chromium extension is not connected.\n' +
'If the extension is already installed, try: opencli daemon restart\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 daemon restart\n' +
'If the extension is not installed:\n' +
' 1. Download from https://github.com/jackwener/opencli/releases\n' +
' 2. Open chrome://extensions/ → Enable Developer Mode\n' +
Expand Down
62 changes: 62 additions & 0 deletions src/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
EmptyResultError,
selectorError,
toEnvelope,
attachTraceReceipt,
} from './errors.js';

describe('Error type hierarchy', () => {
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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({
Expand Down
89 changes: 89 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> or pass --profile <name>',
],
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 <name>'],
};
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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Loading