Skip to content

Commit 85d73b5

Browse files
authored
refactor(doctor/status): unify daemon health checks into getDaemonHealth() (#873)
- Add getDaemonHealth() returning 'stopped' | 'no-extension' | 'ready' - Delete discover.ts (thin wrapper with no value) - Bridge uses getDaemonHealth() + _pollUntilReady() (eliminates duplicate polling) - Doctor simplified: live check auto-starts daemon; no-live mode does minimal auto-start only when stopped (avoids misreporting idle-exit as failure) - CommanderAdapter preserves error message/hint detail (not just generic title) - All callers use single unified status entry point
1 parent 79fb082 commit 85d73b5

9 files changed

Lines changed: 140 additions & 146 deletions

File tree

src/browser.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,7 @@ describe('BrowserBridge state', () => {
135135
});
136136

137137
it('fails fast when daemon is running but extension is disconnected', async () => {
138-
vi.spyOn(daemonClient, 'isExtensionConnected').mockResolvedValue(false);
139-
vi.spyOn(daemonClient, 'fetchDaemonStatus').mockResolvedValue({ extensionConnected: false } as any);
138+
vi.spyOn(daemonClient, 'getDaemonHealth').mockResolvedValue({ state: 'no-extension', status: { extensionConnected: false } as any });
140139

141140
const bridge = new BrowserBridge();
142141

src/browser/bridge.ts

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import * as fs from 'node:fs';
99
import type { IPage } from '../types.js';
1010
import type { IBrowserFactory } from '../runtime.js';
1111
import { Page } from './page.js';
12-
import { fetchDaemonStatus, isExtensionConnected } from './daemon-client.js';
12+
import { getDaemonHealth } from './daemon-client.js';
1313
import { DEFAULT_DAEMON_PORT } from '../constants.js';
1414
import { BrowserConnectError } from '../errors.js';
1515

@@ -61,23 +61,18 @@ export class BrowserBridge implements IBrowserFactory {
6161
const effectiveSeconds = (timeoutSeconds && timeoutSeconds > 0) ? timeoutSeconds : Math.ceil(DAEMON_SPAWN_TIMEOUT / 1000);
6262
const timeoutMs = effectiveSeconds * 1000;
6363

64-
// Single status check instead of two separate fetchDaemonStatus() calls
65-
const status = await fetchDaemonStatus();
64+
const health = await getDaemonHealth();
6665

67-
// Fast path: extension already connected
68-
if (status?.extensionConnected) return;
66+
// Fast path: everything ready
67+
if (health.state === 'ready') return;
6968

7069
// Daemon running but no extension — wait for extension with progress
71-
if (status !== null) {
70+
if (health.state === 'no-extension') {
7271
if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
7372
process.stderr.write('⏳ Waiting for Chrome/Chromium extension to connect...\n');
7473
process.stderr.write(' Make sure Chrome or Chromium is open and the OpenCLI extension is enabled.\n');
7574
}
76-
const deadline = Date.now() + timeoutMs;
77-
while (Date.now() < deadline) {
78-
await new Promise(resolve => setTimeout(resolve, 200));
79-
if (await isExtensionConnected()) return;
80-
}
75+
if (await this._pollUntilReady(timeoutMs)) return;
8176
throw new BrowserConnectError(
8277
'Browser Bridge extension not connected',
8378
'Install the Browser Bridge:\n' +
@@ -111,14 +106,11 @@ export class BrowserBridge implements IBrowserFactory {
111106
});
112107
this._daemonProc.unref();
113108

114-
// Wait for daemon + extension with faster polling
115-
const deadline = Date.now() + timeoutMs;
116-
while (Date.now() < deadline) {
117-
await new Promise(resolve => setTimeout(resolve, 200));
118-
if (await isExtensionConnected()) return;
119-
}
109+
// Wait for daemon + extension
110+
if (await this._pollUntilReady(timeoutMs)) return;
120111

121-
if ((await fetchDaemonStatus()) !== null) {
112+
const finalHealth = await getDaemonHealth();
113+
if (finalHealth.state === 'no-extension') {
122114
throw new BrowserConnectError(
123115
'Browser Bridge extension not connected',
124116
'Install the Browser Bridge:\n' +
@@ -135,4 +127,15 @@ export class BrowserBridge implements IBrowserFactory {
135127
'daemon-not-running',
136128
);
137129
}
130+
131+
/** Poll getDaemonHealth() until state is 'ready' or deadline is reached. */
132+
private async _pollUntilReady(timeoutMs: number): Promise<boolean> {
133+
const deadline = Date.now() + timeoutMs;
134+
while (Date.now() < deadline) {
135+
await new Promise(resolve => setTimeout(resolve, 200));
136+
const h = await getDaemonHealth();
137+
if (h.state === 'ready') return true;
138+
}
139+
return false;
140+
}
138141
}

src/browser/daemon-client.test.ts

Lines changed: 34 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
22

33
import {
44
fetchDaemonStatus,
5-
isDaemonRunning,
6-
isExtensionConnected,
5+
getDaemonHealth,
76
requestDaemonShutdown,
87
} from './daemon-client.js';
98

@@ -63,41 +62,48 @@ describe('daemon-client', () => {
6362
);
6463
});
6564

66-
it('isDaemonRunning reflects shared status availability', async () => {
65+
it('getDaemonHealth returns stopped when daemon is not reachable', async () => {
66+
vi.mocked(fetch).mockRejectedValue(new Error('ECONNREFUSED'));
67+
68+
await expect(getDaemonHealth()).resolves.toEqual({ state: 'stopped', status: null });
69+
});
70+
71+
it('getDaemonHealth returns no-extension when daemon is running but extension disconnected', async () => {
72+
const status = {
73+
ok: true,
74+
pid: 123,
75+
uptime: 10,
76+
extensionConnected: false,
77+
pending: 0,
78+
lastCliRequestTime: Date.now(),
79+
memoryMB: 16,
80+
port: 19825,
81+
};
6782
vi.mocked(fetch).mockResolvedValue({
6883
ok: true,
69-
json: () =>
70-
Promise.resolve({
71-
ok: true,
72-
pid: 123,
73-
uptime: 10,
74-
extensionConnected: false,
75-
pending: 0,
76-
lastCliRequestTime: Date.now(),
77-
memoryMB: 16,
78-
port: 19825,
79-
}),
84+
json: () => Promise.resolve(status),
8085
} as Response);
8186

82-
await expect(isDaemonRunning()).resolves.toBe(true);
87+
await expect(getDaemonHealth()).resolves.toEqual({ state: 'no-extension', status });
8388
});
8489

85-
it('isExtensionConnected reflects shared status payload', async () => {
90+
it('getDaemonHealth returns ready when daemon and extension are both connected', async () => {
91+
const status = {
92+
ok: true,
93+
pid: 123,
94+
uptime: 10,
95+
extensionConnected: true,
96+
extensionVersion: '1.2.3',
97+
pending: 0,
98+
lastCliRequestTime: Date.now(),
99+
memoryMB: 32,
100+
port: 19825,
101+
};
86102
vi.mocked(fetch).mockResolvedValue({
87103
ok: true,
88-
json: () =>
89-
Promise.resolve({
90-
ok: true,
91-
pid: 123,
92-
uptime: 10,
93-
extensionConnected: false,
94-
pending: 0,
95-
lastCliRequestTime: Date.now(),
96-
memoryMB: 16,
97-
port: 19825,
98-
}),
104+
json: () => Promise.resolve(status),
99105
} as Response);
100106

101-
await expect(isExtensionConnected()).resolves.toBe(false);
107+
await expect(getDaemonHealth()).resolves.toEqual({ state: 'ready', status });
102108
});
103109
});

src/browser/daemon-client.ts

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,22 @@ export async function fetchDaemonStatus(opts?: { timeout?: number }): Promise<Da
9191
}
9292
}
9393

94+
export type DaemonHealth =
95+
| { state: 'stopped'; status: null }
96+
| { state: 'no-extension'; status: DaemonStatus }
97+
| { state: 'ready'; status: DaemonStatus };
98+
99+
/**
100+
* Unified daemon health check — single entry point for all status queries.
101+
* Replaces isDaemonRunning(), isExtensionConnected(), and checkDaemonStatus().
102+
*/
103+
export async function getDaemonHealth(opts?: { timeout?: number }): Promise<DaemonHealth> {
104+
const status = await fetchDaemonStatus(opts);
105+
if (!status) return { state: 'stopped', status: null };
106+
if (!status.extensionConnected) return { state: 'no-extension', status };
107+
return { state: 'ready', status };
108+
}
109+
94110
export async function requestDaemonShutdown(opts?: { timeout?: number }): Promise<boolean> {
95111
try {
96112
const res = await requestDaemon('/shutdown', { method: 'POST', timeout: opts?.timeout ?? 5000 });
@@ -100,21 +116,6 @@ export async function requestDaemonShutdown(opts?: { timeout?: number }): Promis
100116
}
101117
}
102118

103-
/**
104-
* Check if daemon is running.
105-
*/
106-
export async function isDaemonRunning(): Promise<boolean> {
107-
return (await fetchDaemonStatus()) !== null;
108-
}
109-
110-
/**
111-
* Check if daemon is running AND the extension is connected.
112-
*/
113-
export async function isExtensionConnected(): Promise<boolean> {
114-
const status = await fetchDaemonStatus();
115-
return !!status?.extensionConnected;
116-
}
117-
118119
/**
119120
* Send a command to the daemon and wait for a result.
120121
* Retries up to 4 times: network errors retry at 500ms,

src/browser/discover.ts

Lines changed: 0 additions & 26 deletions
This file was deleted.

src/browser/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
export { Page } from './page.js';
99
export { BrowserBridge } from './bridge.js';
1010
export { CDPBridge } from './cdp.js';
11-
export { isDaemonRunning } from './daemon-client.js';
11+
export { getDaemonHealth } from './daemon-client.js';
12+
export type { DaemonHealth } from './daemon-client.js';
1213
export { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
1314
export { generateStealthJs } from './stealth.js';
1415
export type { DomSnapshotOptions } from './dom-snapshot.js';

src/commanderAdapter.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import {
3030
AdapterLoadError,
3131
CommandExecutionError,
3232
} from './errors.js';
33-
import { checkDaemonStatus } from './browser/discover.js';
33+
import { getDaemonHealth } from './browser/daemon-client.js';
3434
import { isDiagnosticEnabled } from './diagnostic.js';
3535

3636
export function normalizeArgValue(argType: string | undefined, value: unknown, name: string): unknown {
@@ -204,14 +204,14 @@ function emitAutoFixHint(cmdName: string): void {
204204
async function renderError(err: unknown, cmdName: string, verbose: boolean): Promise<void> {
205205
// ── BrowserConnectError: real-time diagnosis, kind as fallback ────────
206206
if (err instanceof BrowserConnectError) {
207-
console.error(chalk.red('🔌 Browser Bridge not connected'));
207+
console.error(chalk.red(`🔌 ${err.message}`));
208+
if (err.hint) console.error(chalk.yellow(`→ ${err.hint}`));
208209
console.error();
209210
try {
210-
// 300ms matches execution.ts — localhost responds in <50ms when running.
211-
const status = await checkDaemonStatus({ timeout: 300 });
212-
renderBridgeStatus(status.running, status.extensionConnected);
211+
const health = await getDaemonHealth({ timeout: 300 });
212+
renderBridgeStatus(health.state !== 'stopped', health.state === 'ready');
213213
} catch (_statusErr) {
214-
// checkDaemonStatus itself failed — derive best-guess state from kind.
214+
// getDaemonHealth itself failed — derive best-guess state from kind.
215215
const running = err.kind !== 'daemon-not-running';
216216
const extensionConnected = err.kind === 'command-failed';
217217
renderBridgeStatus(running, extensionConnected);

src/doctor.test.ts

Lines changed: 33 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,14 @@
11
import { beforeEach, describe, expect, it, vi } from 'vitest';
22

3-
const { mockCheckDaemonStatus, mockListSessions, mockConnect, mockClose } = vi.hoisted(() => ({
4-
mockCheckDaemonStatus: vi.fn(),
3+
const { mockGetDaemonHealth, mockListSessions, mockConnect, mockClose } = vi.hoisted(() => ({
4+
mockGetDaemonHealth: vi.fn(),
55
mockListSessions: vi.fn(),
66
mockConnect: vi.fn(),
77
mockClose: vi.fn(),
88
}));
99

10-
vi.mock('./browser/discover.js', () => ({
11-
checkDaemonStatus: mockCheckDaemonStatus,
12-
}));
13-
1410
vi.mock('./browser/daemon-client.js', () => ({
11+
getDaemonHealth: mockGetDaemonHealth,
1512
listSessions: mockListSessions,
1613
}));
1714

@@ -113,35 +110,33 @@ describe('doctor report rendering', () => {
113110
expect(text).toContain('Daemon connectivity is unstable.');
114111
});
115112

116-
it('reports consistent status when live check auto-starts the daemon', async () => {
117-
// checkDaemonStatus is called twice: once for auto-start check, once for final status.
118-
// First call: daemon not running (triggers auto-start attempt)
119-
mockCheckDaemonStatus.mockResolvedValueOnce({ running: false, extensionConnected: false });
120-
// Auto-start attempt via BrowserBridge.connect fails
113+
it('reports daemon not running when no-live and auto-start fails', async () => {
114+
// no-live mode: getDaemonHealth called twice (initial check + final status)
115+
// Initial: stopped → triggers auto-start attempt
116+
mockGetDaemonHealth.mockResolvedValueOnce({ state: 'stopped', status: null });
117+
// Auto-start fails
121118
mockConnect.mockRejectedValueOnce(new Error('Could not start daemon'));
122-
// Second call: daemon still not running after failed auto-start
123-
mockCheckDaemonStatus.mockResolvedValueOnce({ running: false, extensionConnected: false });
119+
// Final: still stopped
120+
mockGetDaemonHealth.mockResolvedValueOnce({ state: 'stopped', status: null });
124121

125122
const report = await runBrowserDoctor({ live: false });
126123

127-
// Status reflects daemon not running
128124
expect(report.daemonRunning).toBe(false);
129125
expect(report.extensionConnected).toBe(false);
130-
// checkDaemonStatus called twice (initial + final)
131-
expect(mockCheckDaemonStatus).toHaveBeenCalledTimes(2);
132-
// Should report daemon not running
126+
expect(mockGetDaemonHealth).toHaveBeenCalledTimes(2);
133127
expect(report.issues).toEqual(expect.arrayContaining([
134128
expect.stringContaining('Daemon is not running'),
135129
]));
136130
});
137131

138-
it('reports flapping when live check succeeds but final status flips disconnected', async () => {
139-
mockCheckDaemonStatus.mockResolvedValueOnce({ running: true, extensionConnected: false });
132+
it('reports flapping when live check succeeds but final status shows extension disconnected', async () => {
133+
// Live check succeeds
140134
mockConnect.mockResolvedValueOnce({
141135
evaluate: vi.fn().mockResolvedValue(2),
142136
});
143137
mockClose.mockResolvedValueOnce(undefined);
144-
mockCheckDaemonStatus.mockResolvedValueOnce({ running: true, extensionConnected: false });
138+
// After live check, getDaemonHealth shows no-extension
139+
mockGetDaemonHealth.mockResolvedValueOnce({ state: 'no-extension', status: { extensionConnected: false } });
145140

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

@@ -154,12 +149,13 @@ describe('doctor report rendering', () => {
154149
});
155150

156151
it('reports daemon flapping when live check succeeds but daemon disappears afterward', async () => {
157-
mockCheckDaemonStatus.mockResolvedValueOnce({ running: true, extensionConnected: true });
152+
// Live check succeeds
158153
mockConnect.mockResolvedValueOnce({
159154
evaluate: vi.fn().mockResolvedValue(2),
160155
});
161156
mockClose.mockResolvedValueOnce(undefined);
162-
mockCheckDaemonStatus.mockResolvedValueOnce({ running: false, extensionConnected: false });
157+
// After live check, getDaemonHealth shows stopped
158+
mockGetDaemonHealth.mockResolvedValueOnce({ state: 'stopped', status: null });
163159

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

@@ -173,18 +169,31 @@ describe('doctor report rendering', () => {
173169

174170
it('uses the fast default timeout for live connectivity checks', async () => {
175171
let timeoutSeen: number | undefined;
176-
mockCheckDaemonStatus.mockResolvedValueOnce({ running: true, extensionConnected: true });
177172
mockConnect.mockImplementationOnce(async (opts?: { timeout?: number }) => {
178173
timeoutSeen = opts?.timeout;
179174
return {
180175
evaluate: vi.fn().mockResolvedValue(2),
181176
};
182177
});
183178
mockClose.mockResolvedValueOnce(undefined);
184-
mockCheckDaemonStatus.mockResolvedValueOnce({ running: true, extensionConnected: true });
179+
mockGetDaemonHealth.mockResolvedValueOnce({ state: 'ready', status: { extensionConnected: true } });
185180

186181
await runBrowserDoctor({ live: true });
187182

188183
expect(timeoutSeen).toBe(8);
189184
});
185+
186+
it('skips auto-start in no-live mode when daemon is already running', async () => {
187+
// no-live mode but daemon already running (no-extension)
188+
mockGetDaemonHealth.mockResolvedValueOnce({ state: 'no-extension', status: { extensionConnected: false } });
189+
// Final status: same
190+
mockGetDaemonHealth.mockResolvedValueOnce({ state: 'no-extension', status: { extensionConnected: false } });
191+
192+
const report = await runBrowserDoctor({ live: false });
193+
194+
// Should NOT have tried auto-start since daemon was already running
195+
expect(mockConnect).not.toHaveBeenCalled();
196+
expect(report.daemonRunning).toBe(true);
197+
expect(report.extensionConnected).toBe(false);
198+
});
190199
});

0 commit comments

Comments
 (0)