Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src/browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ describe('BrowserBridge state', () => {

const bridge = new BrowserBridge();

await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Browser Extension is not connected');
await expect(bridge.connect({ timeout: 0.1 })).rejects.toThrow('Browser Bridge extension not connected');
});
});

Expand Down
29 changes: 19 additions & 10 deletions src/browser/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { IBrowserFactory } from '../runtime.js';
import { Page } from './page.js';
import { fetchDaemonStatus, isExtensionConnected } from './daemon-client.js';
import { DEFAULT_DAEMON_PORT } from '../constants.js';
import { BrowserConnectError } from '../errors.js';

const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension

Expand Down Expand Up @@ -77,9 +78,13 @@ export class BrowserBridge implements IBrowserFactory {
await new Promise(resolve => setTimeout(resolve, 200));
if (await isExtensionConnected()) return;
}
throw new Error(
'Daemon is running but the Browser Extension is not connected.\n' +
'Please install and enable the opencli Browser Bridge extension in Chrome or Chromium.',
throw new BrowserConnectError(
'Browser Bridge extension not connected',
'Install the Browser Bridge:\n' +
' 1. Download: https://github.com/jackwener/opencli/releases\n' +
' 2. In Chrome or Chromium, open chrome://extensions → Developer Mode → Load unpacked\n' +
' Then run: opencli doctor',
'extension-not-connected',
);
}

Expand Down Expand Up @@ -114,16 +119,20 @@ export class BrowserBridge implements IBrowserFactory {
}

if ((await fetchDaemonStatus()) !== null) {
throw new Error(
'Daemon is running but the Browser Extension is not connected.\n' +
'Please install and enable the opencli Browser Bridge extension in Chrome or Chromium.',
throw new BrowserConnectError(
'Browser Bridge extension not connected',
'Install the Browser Bridge:\n' +
' 1. Download: https://github.com/jackwener/opencli/releases\n' +
' 2. In Chrome or Chromium, open chrome://extensions → Developer Mode → Load unpacked\n' +
' Then run: opencli doctor',
'extension-not-connected',
);
}

throw new Error(
'Failed to start opencli daemon. Try running manually:\n' +
` node ${daemonPath}\n` +
`Make sure port ${DEFAULT_DAEMON_PORT} is available.`,
throw new BrowserConnectError(
'Failed to start opencli daemon',
`Try running manually:\n node ${daemonPath}\nMake sure port ${DEFAULT_DAEMON_PORT} is available.`,
'daemon-not-running',
);
}
}
79 changes: 79 additions & 0 deletions src/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,32 @@ describe('doctor report rendering', () => {
expect(text).toContain('[SKIP] Connectivity: skipped (--no-live)');
});

it('renders unstable extension state when live connectivity and status disagree', () => {
const text = strip(renderBrowserDoctorReport({
daemonRunning: true,
extensionConnected: true,
extensionFlaky: true,
connectivity: { ok: true, durationMs: 1234 },
issues: ['Extension connection is unstable.'],
}));

expect(text).toContain('[WARN] Extension: unstable');
expect(text).toContain('Extension connection is unstable.');
});

it('renders unstable daemon state when live connectivity and status disagree', () => {
const text = strip(renderBrowserDoctorReport({
daemonRunning: false,
daemonFlaky: true,
extensionConnected: false,
connectivity: { ok: true, durationMs: 1234 },
issues: ['Daemon connectivity is unstable.'],
}));

expect(text).toContain('[WARN] Daemon: unstable');
expect(text).toContain('Daemon connectivity is unstable.');
});

it('reports consistent status when live check auto-starts the daemon', async () => {
// checkDaemonStatus is called twice: once for auto-start check, once for final status.
// First call: daemon not running (triggers auto-start attempt)
Expand All @@ -108,4 +134,57 @@ describe('doctor report rendering', () => {
expect.stringContaining('Daemon is not running'),
]));
});

it('reports flapping when live check succeeds but final status flips disconnected', async () => {
mockCheckDaemonStatus.mockResolvedValueOnce({ running: true, extensionConnected: false });
mockConnect.mockResolvedValueOnce({
evaluate: vi.fn().mockResolvedValue(2),
});
mockClose.mockResolvedValueOnce(undefined);
mockCheckDaemonStatus.mockResolvedValueOnce({ running: true, extensionConnected: false });

const report = await runBrowserDoctor({ live: true });

expect(report.daemonRunning).toBe(true);
expect(report.extensionConnected).toBe(false);
expect(report.extensionFlaky).toBe(true);
expect(report.issues).toEqual(expect.arrayContaining([
expect.stringContaining('Extension connection is unstable'),
]));
});

it('reports daemon flapping when live check succeeds but daemon disappears afterward', async () => {
mockCheckDaemonStatus.mockResolvedValueOnce({ running: true, extensionConnected: true });
mockConnect.mockResolvedValueOnce({
evaluate: vi.fn().mockResolvedValue(2),
});
mockClose.mockResolvedValueOnce(undefined);
mockCheckDaemonStatus.mockResolvedValueOnce({ running: false, extensionConnected: false });

const report = await runBrowserDoctor({ live: true });

expect(report.daemonRunning).toBe(false);
expect(report.daemonFlaky).toBe(true);
expect(report.extensionConnected).toBe(false);
expect(report.issues).toEqual(expect.arrayContaining([
expect.stringContaining('Daemon connectivity is unstable'),
]));
});

it('uses the fast default timeout for live connectivity checks', async () => {
let timeoutSeen: number | undefined;
mockCheckDaemonStatus.mockResolvedValueOnce({ running: true, extensionConnected: true });
mockConnect.mockImplementationOnce(async (opts?: { timeout?: number }) => {
timeoutSeen = opts?.timeout;
return {
evaluate: vi.fn().mockResolvedValue(2),
};
});
mockClose.mockResolvedValueOnce(undefined);
mockCheckDaemonStatus.mockResolvedValueOnce({ running: true, extensionConnected: true });

await runBrowserDoctor({ live: true });

expect(timeoutSeen).toBe(8);
});
});
48 changes: 39 additions & 9 deletions src/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { listSessions } from './browser/daemon-client.js';
import { getErrorMessage } from './errors.js';
import { getRuntimeLabel } from './runtime-detect.js';

const DOCTOR_LIVE_TIMEOUT_SECONDS = 8;

export type DoctorOptions = {
yes?: boolean;
live?: boolean;
Expand All @@ -29,7 +31,9 @@ export type ConnectivityResult = {
export type DoctorReport = {
cliVersion?: string;
daemonRunning: boolean;
daemonFlaky?: boolean;
extensionConnected: boolean;
extensionFlaky?: boolean;
extensionVersion?: string;
connectivity?: ConnectivityResult;
sessions?: Array<{ workspace: string; windowId: number; tabCount: number; idleMsRemaining: number }>;
Expand All @@ -43,7 +47,7 @@ export async function checkConnectivity(opts?: { timeout?: number }): Promise<Co
const start = Date.now();
try {
const bridge = new BrowserBridge();
const page = await bridge.connect({ timeout: opts?.timeout ?? 8 });
const page = await bridge.connect({ timeout: opts?.timeout ?? DOCTOR_LIVE_TIMEOUT_SECONDS });
// Try a simple eval to verify end-to-end connectivity
await page.evaluate('1 + 1');
await bridge.close();
Expand Down Expand Up @@ -74,15 +78,29 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
}

const status = await checkDaemonStatus();
const daemonFlaky = !!(connectivity?.ok && !status.running);
const extensionFlaky = !!(connectivity?.ok && status.running && !status.extensionConnected);
const daemonRunning = status.running;
const extensionConnected = status.extensionConnected;
const sessions = opts.sessions && status.running && status.extensionConnected
? await listSessions() as Array<{ workspace: string; windowId: number; tabCount: number; idleMsRemaining: number }>
: undefined;

const issues: string[] = [];
if (!status.running) {
if (daemonFlaky) {
issues.push(
'Daemon connectivity is unstable. The live browser test succeeded, but the daemon was no longer running immediately afterward.\n' +
'This usually means the daemon crashed or exited right after serving the live probe.',
);
} else if (!daemonRunning) {
issues.push('Daemon is not running. It should start automatically when you run an opencli browser command.');
}
if (status.running && !status.extensionConnected) {
if (extensionFlaky) {
issues.push(
'Extension connection is unstable. The live browser test succeeded, but the daemon reported the extension disconnected immediately afterward.\n' +
'This usually means the Browser Bridge service worker is reconnecting slowly or Chrome suspended it.',
);
} else if (daemonRunning && !extensionConnected) {
issues.push(
'Daemon is running but the Chrome/Chromium extension is not connected.\n' +
'Please install the opencli Browser Bridge extension:\n' +
Expand All @@ -107,8 +125,10 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor

return {
cliVersion: opts.cliVersion,
daemonRunning: status.running,
extensionConnected: status.extensionConnected,
daemonRunning,
daemonFlaky,
extensionConnected,
extensionFlaky,
extensionVersion: status.extensionVersion,
connectivity,
sessions,
Expand All @@ -120,13 +140,23 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
const lines = [chalk.bold(`opencli v${report.cliVersion ?? 'unknown'} doctor`) + chalk.dim(` (${getRuntimeLabel()})`), ''];

// Daemon status
const daemonIcon = report.daemonRunning ? chalk.green('[OK]') : chalk.red('[MISSING]');
lines.push(`${daemonIcon} Daemon: ${report.daemonRunning ? `running on port ${DEFAULT_DAEMON_PORT}` : 'not running'}`);
const daemonIcon = report.daemonFlaky
? chalk.yellow('[WARN]')
: report.daemonRunning ? chalk.green('[OK]') : chalk.red('[MISSING]');
const daemonLabel = report.daemonFlaky
? 'unstable (running during live check, then stopped)'
: report.daemonRunning ? `running on port ${DEFAULT_DAEMON_PORT}` : 'not running';
lines.push(`${daemonIcon} Daemon: ${daemonLabel}`);

// Extension status
const extIcon = report.extensionConnected ? chalk.green('[OK]') : chalk.yellow('[MISSING]');
const extIcon = report.extensionFlaky
? chalk.yellow('[WARN]')
: report.extensionConnected ? chalk.green('[OK]') : chalk.yellow('[MISSING]');
const extVersion = report.extensionVersion ? chalk.dim(` (v${report.extensionVersion})`) : '';
lines.push(`${extIcon} Extension: ${report.extensionConnected ? 'connected' : 'not connected'}${extVersion}`);
const extLabel = report.extensionFlaky
? 'unstable (connected during live check, then disconnected)'
: report.extensionConnected ? 'connected' : 'not connected';
lines.push(`${extIcon} Extension: ${extLabel}${extVersion}`);

// Connectivity
if (report.connectivity) {
Expand Down
16 changes: 1 addition & 15 deletions src/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,11 @@ import { type CliCommand, type InternalCliCommand, type Arg, type CommandArgs, S
import type { IPage } from './types.js';
import { pathToFileURL } from 'node:url';
import { executePipeline } from './pipeline/index.js';
import { AdapterLoadError, ArgumentError, BrowserConnectError, CommandExecutionError, getErrorMessage } from './errors.js';
import { AdapterLoadError, ArgumentError, CommandExecutionError, getErrorMessage } from './errors.js';
import { isDiagnosticEnabled, collectDiagnostic, emitDiagnostic } from './diagnostic.js';
import { shouldUseBrowserSession } from './capabilityRouting.js';
import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT } from './runtime.js';
import { emitHook, type HookContext } from './hooks.js';
import { checkDaemonStatus } from './browser/discover.js';
import { log } from './logger.js';
import { isElectronApp } from './electron-apps.js';
import { probeCDP, resolveElectronEndpoint } from './launcher.js';
Expand Down Expand Up @@ -175,19 +174,6 @@ export async function executeCommand(
} else {
cdpEndpoint = await resolveElectronEndpoint(cmd.site);
}
} else {
// Browser Bridge: fail-fast when daemon is up but extension is missing.
// 300ms timeout avoids a full 2s wait on cold-start.
const status = await checkDaemonStatus({ timeout: 300 });
if (status.running && !status.extensionConnected) {
throw new BrowserConnectError(
'Browser Bridge extension not connected',
'Install the Browser Bridge:\n' +
' 1. Download: https://github.com/jackwener/opencli/releases\n' +
' 2. In Chrome or Chromium, open chrome://extensions → Developer Mode → Load unpacked\n' +
' Then run: opencli doctor',
);
}
}

ensureRequiredEnv(cmd);
Expand Down
Loading