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
103 changes: 103 additions & 0 deletions src/browser/daemon-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import {
fetchDaemonStatus,
isDaemonRunning,
isExtensionConnected,
requestDaemonShutdown,
} from './daemon-client.js';

describe('daemon-client', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn());
});

afterEach(() => {
vi.restoreAllMocks();
});

it('fetchDaemonStatus sends the shared status request and returns parsed data', async () => {
const status = {
ok: true,
pid: 123,
uptime: 10,
extensionConnected: true,
extensionVersion: '1.2.3',
pending: 0,
lastCliRequestTime: Date.now(),
memoryMB: 32,
port: 19825,
};
const fetchMock = vi.mocked(fetch);
fetchMock.mockResolvedValue({
ok: true,
json: () => Promise.resolve(status),
} as Response);

await expect(fetchDaemonStatus()).resolves.toEqual(status);
expect(fetchMock).toHaveBeenCalledWith(
expect.stringMatching(/\/status$/),
expect.objectContaining({
headers: expect.objectContaining({ 'X-OpenCLI': '1' }),
}),
);
});

it('fetchDaemonStatus returns null on network failure', async () => {
vi.mocked(fetch).mockRejectedValue(new Error('ECONNREFUSED'));

await expect(fetchDaemonStatus()).resolves.toBeNull();
});

it('requestDaemonShutdown POSTs to the shared shutdown endpoint', async () => {
const fetchMock = vi.mocked(fetch);
fetchMock.mockResolvedValue({ ok: true } as Response);

await expect(requestDaemonShutdown()).resolves.toBe(true);
expect(fetchMock).toHaveBeenCalledWith(
expect.stringMatching(/\/shutdown$/),
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ 'X-OpenCLI': '1' }),
}),
);
});

it('isDaemonRunning reflects shared status availability', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
ok: true,
pid: 123,
uptime: 10,
extensionConnected: false,
pending: 0,
lastCliRequestTime: Date.now(),
memoryMB: 16,
port: 19825,
}),
} as Response);

await expect(isDaemonRunning()).resolves.toBe(true);
});

it('isExtensionConnected reflects shared status payload', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
ok: true,
pid: 123,
uptime: 10,
extensionConnected: false,
pending: 0,
lastCliRequestTime: Date.now(),
memoryMB: 16,
port: 19825,
}),
} as Response);

await expect(isExtensionConnected()).resolves.toBe(false);
});
});
78 changes: 49 additions & 29 deletions src/browser/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { isTransientBrowserError } from './errors.js';

const DAEMON_PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
const OPENCLI_HEADERS = { 'X-OpenCLI': '1' };

let _idCounter = 0;

Expand Down Expand Up @@ -44,42 +45,65 @@ export interface DaemonResult {
error?: string;
}

/**
* Check if daemon is running.
*/
export async function isDaemonRunning(): Promise<boolean> {
export interface DaemonStatus {
ok: boolean;
pid: number;
uptime: number;
extensionConnected: boolean;
extensionVersion?: string;
pending: number;
lastCliRequestTime: number;
memoryMB: number;
port: number;
}

async function requestDaemon(pathname: string, init?: RequestInit & { timeout?: number }): Promise<Response> {
const { timeout = 2000, headers, ...rest } = init ?? {};
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2000);
const res = await fetch(`${DAEMON_URL}/status`, {
headers: { 'X-OpenCLI': '1' },
return await fetch(`${DAEMON_URL}${pathname}`, {
...rest,
headers: { ...OPENCLI_HEADERS, ...headers },
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
}

export async function fetchDaemonStatus(opts?: { timeout?: number }): Promise<DaemonStatus | null> {
try {
const res = await requestDaemon('/status', { timeout: opts?.timeout ?? 2000 });
if (!res.ok) return null;
return await res.json() as DaemonStatus;
} catch {
return null;
}
}

export async function requestDaemonShutdown(opts?: { timeout?: number }): Promise<boolean> {
try {
const res = await requestDaemon('/shutdown', { method: 'POST', timeout: opts?.timeout ?? 5000 });
return res.ok;
} catch {
return false;
}
}

/**
* Check if daemon is running.
*/
export async function isDaemonRunning(): Promise<boolean> {
return (await fetchDaemonStatus()) !== null;
}

/**
* Check if daemon is running AND the extension is connected.
*/
export async function isExtensionConnected(): Promise<boolean> {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2000);
const res = await fetch(`${DAEMON_URL}/status`, {
headers: { 'X-OpenCLI': '1' },
signal: controller.signal,
});
clearTimeout(timer);
if (!res.ok) return false;
const data = await res.json() as { extensionConnected?: boolean };
return !!data.extensionConnected;
} catch {
return false;
}
const status = await fetchDaemonStatus();
return !!status?.extensionConnected;
}

/**
Expand All @@ -98,16 +122,12 @@ export async function sendCommand(
const id = generateId();
const command: DaemonCommand = { id, action, ...params };
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 30000);

const res = await fetch(`${DAEMON_URL}/command`, {
const res = await requestDaemon('/command', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-OpenCLI': '1' },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(command),
signal: controller.signal,
timeout: 30000,
});
clearTimeout(timer);

const result = (await res.json()) as DaemonResult;

Expand Down
25 changes: 8 additions & 17 deletions src/browser/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@
* Daemon discovery — checks if the daemon is running.
*/

import { DEFAULT_DAEMON_PORT } from '../constants.js';
import { isDaemonRunning } from './daemon-client.js';
import { fetchDaemonStatus, isDaemonRunning } from './daemon-client.js';

export { isDaemonRunning };

Expand All @@ -15,21 +14,13 @@ export async function checkDaemonStatus(opts?: { timeout?: number }): Promise<{
extensionConnected: boolean;
extensionVersion?: string;
}> {
try {
const port = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), opts?.timeout ?? 2000);
try {
const res = await fetch(`http://127.0.0.1:${port}/status`, {
headers: { 'X-OpenCLI': '1' },
signal: controller.signal,
});
const data = await res.json() as { ok: boolean; extensionConnected: boolean; extensionVersion?: string };
return { running: true, extensionConnected: data.extensionConnected, extensionVersion: data.extensionVersion };
} finally {
clearTimeout(timer);
}
} catch {
const status = await fetchDaemonStatus({ timeout: opts?.timeout ?? 2000 });
if (!status) {
return { running: false, extensionConnected: false };
}
return {
running: true,
extensionConnected: status.extensionConnected,
extensionVersion: status.extensionVersion,
};
}
Loading
Loading