refactor/channel & ipc (#349)
Co-authored-by: paisley <8197966+su8su@users.noreply.github.com> Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
8b45960662
commit
e28eba01e1
@@ -8,11 +8,17 @@ import {
|
||||
registerTransportInvoker,
|
||||
unregisterTransportInvoker,
|
||||
clearTransportBackoff,
|
||||
getApiClientConfig,
|
||||
applyGatewayTransportPreference,
|
||||
createGatewayHttpTransportInvoker,
|
||||
getGatewayWsDiagnosticEnabled,
|
||||
setGatewayWsDiagnosticEnabled,
|
||||
} from '@/lib/api-client';
|
||||
|
||||
describe('api-client', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
window.localStorage.removeItem('clawx:gateway-ws-diagnostic');
|
||||
configureApiClient({
|
||||
enabled: { ws: false, http: false },
|
||||
rules: [{ matcher: /.*/, order: ['ipc'] }],
|
||||
@@ -150,4 +156,82 @@ describe('api-client', () => {
|
||||
expect(wsInvoker).toHaveBeenCalledTimes(2);
|
||||
expect(invoke).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('defaults transport preference to ipc-only', () => {
|
||||
applyGatewayTransportPreference();
|
||||
const config = getApiClientConfig();
|
||||
expect(config.enabled.ws).toBe(false);
|
||||
expect(config.enabled.http).toBe(false);
|
||||
expect(config.rules[0]).toEqual({ matcher: /^gateway:rpc$/, order: ['ipc'] });
|
||||
});
|
||||
|
||||
it('enables ws->http->ipc order when ws diagnostic is on', () => {
|
||||
setGatewayWsDiagnosticEnabled(true);
|
||||
expect(getGatewayWsDiagnosticEnabled()).toBe(true);
|
||||
|
||||
const config = getApiClientConfig();
|
||||
expect(config.enabled.ws).toBe(true);
|
||||
expect(config.enabled.http).toBe(true);
|
||||
expect(config.rules[0]).toEqual({ matcher: /^gateway:rpc$/, order: ['ws', 'http', 'ipc'] });
|
||||
});
|
||||
|
||||
it('parses gateway:httpProxy unified envelope response', async () => {
|
||||
const invoke = vi.mocked(window.electron.ipcRenderer.invoke);
|
||||
invoke.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: { type: 'res', ok: true, payload: { rows: [1, 2] } },
|
||||
},
|
||||
});
|
||||
|
||||
const invoker = createGatewayHttpTransportInvoker();
|
||||
const result = await invoker<{ success: boolean; result: { rows: number[] } }>(
|
||||
'gateway:rpc',
|
||||
['chat.history', { sessionKey: 's1' }],
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result.rows).toEqual([1, 2]);
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
'gateway:httpProxy',
|
||||
expect.objectContaining({
|
||||
path: '/rpc',
|
||||
method: 'POST',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws meaningful error when gateway:httpProxy unified envelope fails', async () => {
|
||||
const invoke = vi.mocked(window.electron.ipcRenderer.invoke);
|
||||
invoke.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: { message: 'proxy unavailable' },
|
||||
});
|
||||
|
||||
const invoker = createGatewayHttpTransportInvoker();
|
||||
await expect(invoker('gateway:rpc', ['chat.history', {}])).rejects.toThrow('proxy unavailable');
|
||||
});
|
||||
|
||||
it('normalizes raw gateway:httpProxy payload into ipc-style envelope', async () => {
|
||||
const invoke = vi.mocked(window.electron.ipcRenderer.invoke);
|
||||
invoke.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: { channels: [{ id: 'telegram-default' }] },
|
||||
},
|
||||
});
|
||||
|
||||
const invoker = createGatewayHttpTransportInvoker();
|
||||
const result = await invoker<{ success: boolean; result: { channels: Array<{ id: string }> } }>(
|
||||
'gateway:rpc',
|
||||
['channels.status', { probe: false }],
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result.channels[0].id).toBe('telegram-default');
|
||||
});
|
||||
});
|
||||
|
||||
166
tests/unit/chat-runtime-event-handlers.test.ts
Normal file
166
tests/unit/chat-runtime-event-handlers.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const clearErrorRecoveryTimer = vi.fn();
|
||||
const clearHistoryPoll = vi.fn();
|
||||
const collectToolUpdates = vi.fn(() => []);
|
||||
const extractImagesAsAttachedFiles = vi.fn(() => []);
|
||||
const extractMediaRefs = vi.fn(() => []);
|
||||
const extractRawFilePaths = vi.fn(() => []);
|
||||
const getMessageText = vi.fn(() => '');
|
||||
const getToolCallFilePath = vi.fn(() => undefined);
|
||||
const hasErrorRecoveryTimer = vi.fn(() => false);
|
||||
const hasNonToolAssistantContent = vi.fn(() => true);
|
||||
const isToolOnlyMessage = vi.fn(() => false);
|
||||
const isToolResultRole = vi.fn((role: unknown) => role === 'toolresult');
|
||||
const makeAttachedFile = vi.fn((ref: { filePath: string; mimeType: string }) => ({
|
||||
fileName: ref.filePath.split('/').pop() || 'file',
|
||||
mimeType: ref.mimeType,
|
||||
fileSize: 0,
|
||||
preview: null,
|
||||
filePath: ref.filePath,
|
||||
}));
|
||||
const setErrorRecoveryTimer = vi.fn();
|
||||
const upsertToolStatuses = vi.fn((_current, updates) => updates);
|
||||
|
||||
vi.mock('@/stores/chat/helpers', () => ({
|
||||
clearErrorRecoveryTimer: (...args: unknown[]) => clearErrorRecoveryTimer(...args),
|
||||
clearHistoryPoll: (...args: unknown[]) => clearHistoryPoll(...args),
|
||||
collectToolUpdates: (...args: unknown[]) => collectToolUpdates(...args),
|
||||
extractImagesAsAttachedFiles: (...args: unknown[]) => extractImagesAsAttachedFiles(...args),
|
||||
extractMediaRefs: (...args: unknown[]) => extractMediaRefs(...args),
|
||||
extractRawFilePaths: (...args: unknown[]) => extractRawFilePaths(...args),
|
||||
getMessageText: (...args: unknown[]) => getMessageText(...args),
|
||||
getToolCallFilePath: (...args: unknown[]) => getToolCallFilePath(...args),
|
||||
hasErrorRecoveryTimer: (...args: unknown[]) => hasErrorRecoveryTimer(...args),
|
||||
hasNonToolAssistantContent: (...args: unknown[]) => hasNonToolAssistantContent(...args),
|
||||
isToolOnlyMessage: (...args: unknown[]) => isToolOnlyMessage(...args),
|
||||
isToolResultRole: (...args: unknown[]) => isToolResultRole(...args),
|
||||
makeAttachedFile: (...args: unknown[]) => makeAttachedFile(...args),
|
||||
setErrorRecoveryTimer: (...args: unknown[]) => setErrorRecoveryTimer(...args),
|
||||
upsertToolStatuses: (...args: unknown[]) => upsertToolStatuses(...args),
|
||||
}));
|
||||
|
||||
type ChatLikeState = {
|
||||
sending: boolean;
|
||||
activeRunId: string | null;
|
||||
error: string | null;
|
||||
streamingMessage: unknown | null;
|
||||
streamingTools: unknown[];
|
||||
messages: Array<Record<string, unknown>>;
|
||||
pendingToolImages: unknown[];
|
||||
pendingFinal: boolean;
|
||||
lastUserMessageAt: number | null;
|
||||
streamingText: string;
|
||||
loadHistory: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function makeHarness(initial?: Partial<ChatLikeState>) {
|
||||
let state: ChatLikeState = {
|
||||
sending: false,
|
||||
activeRunId: null,
|
||||
error: 'stale error',
|
||||
streamingMessage: null,
|
||||
streamingTools: [],
|
||||
messages: [],
|
||||
pendingToolImages: [],
|
||||
pendingFinal: false,
|
||||
lastUserMessageAt: null,
|
||||
streamingText: '',
|
||||
loadHistory: vi.fn(),
|
||||
...initial,
|
||||
};
|
||||
|
||||
const set = (partial: Partial<ChatLikeState> | ((s: ChatLikeState) => Partial<ChatLikeState>)) => {
|
||||
const next = typeof partial === 'function' ? partial(state) : partial;
|
||||
state = { ...state, ...next };
|
||||
};
|
||||
const get = () => state;
|
||||
return { set, get, read: () => state };
|
||||
}
|
||||
|
||||
describe('chat runtime event handlers', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
hasErrorRecoveryTimer.mockReturnValue(false);
|
||||
collectToolUpdates.mockReturnValue([]);
|
||||
upsertToolStatuses.mockImplementation((_current, updates) => updates);
|
||||
});
|
||||
|
||||
it('marks sending on started event', async () => {
|
||||
const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers');
|
||||
const h = makeHarness({ sending: false, activeRunId: null, error: 'err' });
|
||||
|
||||
handleRuntimeEventState(h.set as never, h.get as never, {}, 'started', 'run-1');
|
||||
const next = h.read();
|
||||
expect(next.sending).toBe(true);
|
||||
expect(next.activeRunId).toBe('run-1');
|
||||
expect(next.error).toBeNull();
|
||||
});
|
||||
|
||||
it('applies delta event and clears stale error when recovery timer exists', async () => {
|
||||
hasErrorRecoveryTimer.mockReturnValue(true);
|
||||
collectToolUpdates.mockReturnValue([{ name: 'tool-a', status: 'running', updatedAt: 1 }]);
|
||||
|
||||
const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers');
|
||||
const h = makeHarness({
|
||||
error: 'old',
|
||||
streamingTools: [],
|
||||
streamingMessage: { role: 'assistant', content: 'old' },
|
||||
});
|
||||
const event = { message: { role: 'assistant', content: 'delta' } };
|
||||
|
||||
handleRuntimeEventState(h.set as never, h.get as never, event, 'delta', 'run-2');
|
||||
const next = h.read();
|
||||
expect(clearErrorRecoveryTimer).toHaveBeenCalledTimes(1);
|
||||
expect(next.error).toBeNull();
|
||||
expect(next.streamingMessage).toEqual(event.message);
|
||||
expect(next.streamingTools).toEqual([{ name: 'tool-a', status: 'running', updatedAt: 1 }]);
|
||||
});
|
||||
|
||||
it('loads history when final event has no message', async () => {
|
||||
const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers');
|
||||
const h = makeHarness();
|
||||
|
||||
handleRuntimeEventState(h.set as never, h.get as never, {}, 'final', 'run-3');
|
||||
const next = h.read();
|
||||
expect(next.pendingFinal).toBe(true);
|
||||
expect(next.streamingMessage).toBeNull();
|
||||
expect(h.read().loadHistory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('handles error event and finalizes immediately when not sending', async () => {
|
||||
const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers');
|
||||
const h = makeHarness({ sending: false, activeRunId: 'r1', lastUserMessageAt: 123 });
|
||||
|
||||
handleRuntimeEventState(h.set as never, h.get as never, { errorMessage: 'boom' }, 'error', 'r1');
|
||||
const next = h.read();
|
||||
expect(clearHistoryPoll).toHaveBeenCalledTimes(1);
|
||||
expect(next.error).toBe('boom');
|
||||
expect(next.sending).toBe(false);
|
||||
expect(next.activeRunId).toBeNull();
|
||||
expect(next.lastUserMessageAt).toBeNull();
|
||||
expect(next.streamingTools).toEqual([]);
|
||||
});
|
||||
|
||||
it('clears runtime state on aborted event', async () => {
|
||||
const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers');
|
||||
const h = makeHarness({
|
||||
sending: true,
|
||||
activeRunId: 'r2',
|
||||
streamingText: 'abc',
|
||||
pendingFinal: true,
|
||||
lastUserMessageAt: 5,
|
||||
pendingToolImages: [{ fileName: 'x' }],
|
||||
});
|
||||
|
||||
handleRuntimeEventState(h.set as never, h.get as never, {}, 'aborted', 'r2');
|
||||
const next = h.read();
|
||||
expect(next.sending).toBe(false);
|
||||
expect(next.activeRunId).toBeNull();
|
||||
expect(next.streamingText).toBe('');
|
||||
expect(next.pendingFinal).toBe(false);
|
||||
expect(next.lastUserMessageAt).toBeNull();
|
||||
expect(next.pendingToolImages).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
123
tests/unit/chat-session-actions.test.ts
Normal file
123
tests/unit/chat-session-actions.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const invokeIpcMock = vi.fn();
|
||||
|
||||
vi.mock('@/lib/api-client', () => ({
|
||||
invokeIpc: (...args: unknown[]) => invokeIpcMock(...args),
|
||||
}));
|
||||
|
||||
type ChatLikeState = {
|
||||
currentSessionKey: string;
|
||||
sessions: Array<{ key: string; displayName?: string }>;
|
||||
messages: Array<{ role: string; timestamp?: number; content?: unknown }>;
|
||||
sessionLabels: Record<string, string>;
|
||||
sessionLastActivity: Record<string, number>;
|
||||
streamingText: string;
|
||||
streamingMessage: unknown | null;
|
||||
streamingTools: unknown[];
|
||||
activeRunId: string | null;
|
||||
error: string | null;
|
||||
pendingFinal: boolean;
|
||||
lastUserMessageAt: number | null;
|
||||
pendingToolImages: unknown[];
|
||||
loadHistory: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function makeHarness(initial?: Partial<ChatLikeState>) {
|
||||
let state: ChatLikeState = {
|
||||
currentSessionKey: 'agent:main:main',
|
||||
sessions: [{ key: 'agent:main:main' }],
|
||||
messages: [],
|
||||
sessionLabels: {},
|
||||
sessionLastActivity: {},
|
||||
streamingText: '',
|
||||
streamingMessage: null,
|
||||
streamingTools: [],
|
||||
activeRunId: null,
|
||||
error: null,
|
||||
pendingFinal: false,
|
||||
lastUserMessageAt: null,
|
||||
pendingToolImages: [],
|
||||
loadHistory: vi.fn(),
|
||||
...initial,
|
||||
};
|
||||
const set = (partial: Partial<ChatLikeState> | ((s: ChatLikeState) => Partial<ChatLikeState>)) => {
|
||||
const patch = typeof partial === 'function' ? partial(state) : partial;
|
||||
state = { ...state, ...patch };
|
||||
};
|
||||
const get = () => state;
|
||||
return { set, get, read: () => state };
|
||||
}
|
||||
|
||||
describe('chat session actions', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
invokeIpcMock.mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
it('switchSession removes empty non-main leaving session and loads history', async () => {
|
||||
const { createSessionActions } = await import('@/stores/chat/session-actions');
|
||||
const h = makeHarness({
|
||||
currentSessionKey: 'agent:foo:session-a',
|
||||
sessions: [{ key: 'agent:foo:session-a' }, { key: 'agent:foo:main' }],
|
||||
messages: [],
|
||||
sessionLabels: { 'agent:foo:session-a': 'A' },
|
||||
sessionLastActivity: { 'agent:foo:session-a': 1 },
|
||||
});
|
||||
const actions = createSessionActions(h.set as never, h.get as never);
|
||||
|
||||
actions.switchSession('agent:foo:main');
|
||||
const next = h.read();
|
||||
expect(next.currentSessionKey).toBe('agent:foo:main');
|
||||
expect(next.sessions.find((s) => s.key === 'agent:foo:session-a')).toBeUndefined();
|
||||
expect(next.sessionLabels['agent:foo:session-a']).toBeUndefined();
|
||||
expect(next.sessionLastActivity['agent:foo:session-a']).toBeUndefined();
|
||||
expect(h.read().loadHistory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('deleteSession updates current session and keeps sidebar consistent', async () => {
|
||||
const { createSessionActions } = await import('@/stores/chat/session-actions');
|
||||
const h = makeHarness({
|
||||
currentSessionKey: 'agent:foo:session-a',
|
||||
sessions: [{ key: 'agent:foo:session-a' }, { key: 'agent:foo:main' }],
|
||||
sessionLabels: { 'agent:foo:session-a': 'A' },
|
||||
sessionLastActivity: { 'agent:foo:session-a': 1 },
|
||||
messages: [{ role: 'user' }],
|
||||
});
|
||||
const actions = createSessionActions(h.set as never, h.get as never);
|
||||
|
||||
await actions.deleteSession('agent:foo:session-a');
|
||||
const next = h.read();
|
||||
expect(invokeIpcMock).toHaveBeenCalledWith('session:delete', 'agent:foo:session-a');
|
||||
expect(next.currentSessionKey).toBe('agent:foo:main');
|
||||
expect(next.sessions.map((s) => s.key)).toEqual(['agent:foo:main']);
|
||||
expect(next.sessionLabels['agent:foo:session-a']).toBeUndefined();
|
||||
expect(next.sessionLastActivity['agent:foo:session-a']).toBeUndefined();
|
||||
expect(h.read().loadHistory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('newSession creates a canonical session key and clears transient state', async () => {
|
||||
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1711111111111);
|
||||
const { createSessionActions } = await import('@/stores/chat/session-actions');
|
||||
const h = makeHarness({
|
||||
currentSessionKey: 'agent:foo:main',
|
||||
sessions: [{ key: 'agent:foo:main' }],
|
||||
messages: [{ role: 'assistant' }],
|
||||
streamingText: 'streaming',
|
||||
activeRunId: 'r1',
|
||||
pendingFinal: true,
|
||||
});
|
||||
const actions = createSessionActions(h.set as never, h.get as never);
|
||||
|
||||
actions.newSession();
|
||||
const next = h.read();
|
||||
expect(next.currentSessionKey).toBe('agent:foo:session-1711111111111');
|
||||
expect(next.sessions.some((s) => s.key === 'agent:foo:session-1711111111111')).toBe(true);
|
||||
expect(next.messages).toEqual([]);
|
||||
expect(next.streamingText).toBe('');
|
||||
expect(next.activeRunId).toBeNull();
|
||||
expect(next.pendingFinal).toBe(false);
|
||||
nowSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
41
tests/unit/gateway-events.test.ts
Normal file
41
tests/unit/gateway-events.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const hostApiFetchMock = vi.fn();
|
||||
const subscribeHostEventMock = vi.fn();
|
||||
|
||||
vi.mock('@/lib/host-api', () => ({
|
||||
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/host-events', () => ({
|
||||
subscribeHostEvent: (...args: unknown[]) => subscribeHostEventMock(...args),
|
||||
}));
|
||||
|
||||
describe('gateway store event wiring', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('subscribes to host events through subscribeHostEvent on init', async () => {
|
||||
hostApiFetchMock.mockResolvedValueOnce({ state: 'running', port: 18789 });
|
||||
|
||||
const handlers = new Map<string, (payload: unknown) => void>();
|
||||
subscribeHostEventMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => {
|
||||
handlers.set(eventName, handler);
|
||||
return () => {};
|
||||
});
|
||||
|
||||
const { useGatewayStore } = await import('@/stores/gateway');
|
||||
await useGatewayStore.getState().init();
|
||||
|
||||
expect(subscribeHostEventMock).toHaveBeenCalledWith('gateway:status', expect.any(Function));
|
||||
expect(subscribeHostEventMock).toHaveBeenCalledWith('gateway:error', expect.any(Function));
|
||||
expect(subscribeHostEventMock).toHaveBeenCalledWith('gateway:notification', expect.any(Function));
|
||||
expect(subscribeHostEventMock).toHaveBeenCalledWith('gateway:chat-message', expect.any(Function));
|
||||
expect(subscribeHostEventMock).toHaveBeenCalledWith('gateway:channel-status', expect.any(Function));
|
||||
|
||||
handlers.get('gateway:status')?.({ state: 'stopped', port: 18789 });
|
||||
expect(useGatewayStore.getState().status.state).toBe('stopped');
|
||||
});
|
||||
});
|
||||
88
tests/unit/host-api.test.ts
Normal file
88
tests/unit/host-api.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const invokeIpcMock = vi.fn();
|
||||
|
||||
vi.mock('@/lib/api-client', () => ({
|
||||
invokeIpc: (...args: unknown[]) => invokeIpcMock(...args),
|
||||
}));
|
||||
|
||||
describe('host-api', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('uses IPC proxy and returns unified envelope json', async () => {
|
||||
invokeIpcMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: { success: true },
|
||||
},
|
||||
});
|
||||
|
||||
const { hostApiFetch } = await import('@/lib/host-api');
|
||||
const result = await hostApiFetch<{ success: boolean }>('/api/settings');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(invokeIpcMock).toHaveBeenCalledWith(
|
||||
'hostapi:fetch',
|
||||
expect.objectContaining({ path: '/api/settings', method: 'GET' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('supports legacy proxy envelope response', async () => {
|
||||
invokeIpcMock.mockResolvedValueOnce({
|
||||
success: true,
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: { ok: 1 },
|
||||
});
|
||||
|
||||
const { hostApiFetch } = await import('@/lib/host-api');
|
||||
const result = await hostApiFetch<{ ok: number }>('/api/settings');
|
||||
expect(result.ok).toBe(1);
|
||||
});
|
||||
|
||||
it('throws proxy error from unified envelope', async () => {
|
||||
invokeIpcMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: { message: 'No handler registered for hostapi:fetch' },
|
||||
});
|
||||
|
||||
const { hostApiFetch } = await import('@/lib/host-api');
|
||||
await expect(hostApiFetch('/api/test')).rejects.toThrow('No handler registered for hostapi:fetch');
|
||||
});
|
||||
|
||||
it('throws message from legacy non-ok envelope', async () => {
|
||||
invokeIpcMock.mockResolvedValueOnce({
|
||||
success: true,
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: { error: 'Invalid Authentication' },
|
||||
});
|
||||
|
||||
const { hostApiFetch } = await import('@/lib/host-api');
|
||||
await expect(hostApiFetch('/api/test')).rejects.toThrow('Invalid Authentication');
|
||||
});
|
||||
|
||||
it('falls back to browser fetch only when IPC channel is unavailable', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ fallback: true }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
invokeIpcMock.mockRejectedValueOnce(new Error('Invalid IPC channel: hostapi:fetch'));
|
||||
|
||||
const { hostApiFetch } = await import('@/lib/host-api');
|
||||
const result = await hostApiFetch<{ fallback: boolean }>('/api/test');
|
||||
|
||||
expect(result.fallback).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://127.0.0.1:3210/api/test',
|
||||
expect.objectContaining({ headers: expect.any(Object) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
74
tests/unit/host-events.test.ts
Normal file
74
tests/unit/host-events.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const addEventListenerMock = vi.fn();
|
||||
const removeEventListenerMock = vi.fn();
|
||||
const eventSourceMock = {
|
||||
addEventListener: addEventListenerMock,
|
||||
removeEventListener: removeEventListenerMock,
|
||||
} as unknown as EventSource;
|
||||
|
||||
const createHostEventSourceMock = vi.fn(() => eventSourceMock);
|
||||
|
||||
vi.mock('@/lib/host-api', () => ({
|
||||
createHostEventSource: () => createHostEventSourceMock(),
|
||||
}));
|
||||
|
||||
describe('host-events', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it('subscribes through IPC for mapped host events', async () => {
|
||||
const onMock = vi.mocked(window.electron.ipcRenderer.on);
|
||||
const offMock = vi.mocked(window.electron.ipcRenderer.off);
|
||||
const captured: Array<(...args: unknown[]) => void> = [];
|
||||
onMock.mockImplementation((_, cb: (...args: unknown[]) => void) => {
|
||||
captured.push(cb);
|
||||
return () => {};
|
||||
});
|
||||
|
||||
const { subscribeHostEvent } = await import('@/lib/host-events');
|
||||
const handler = vi.fn();
|
||||
const unsubscribe = subscribeHostEvent('gateway:status', handler);
|
||||
|
||||
expect(onMock).toHaveBeenCalledWith('gateway:status-changed', expect.any(Function));
|
||||
expect(createHostEventSourceMock).not.toHaveBeenCalled();
|
||||
|
||||
captured[0]({ state: 'running' });
|
||||
expect(handler).toHaveBeenCalledWith({ state: 'running' });
|
||||
|
||||
unsubscribe();
|
||||
expect(offMock).toHaveBeenCalledWith('gateway:status-changed', expect.any(Function));
|
||||
});
|
||||
|
||||
it('does not use SSE fallback by default for unknown events', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const { subscribeHostEvent } = await import('@/lib/host-events');
|
||||
const unsubscribe = subscribeHostEvent('unknown:event', vi.fn());
|
||||
expect(createHostEventSourceMock).not.toHaveBeenCalled();
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'[host-events] no IPC mapping for event "unknown:event", SSE fallback disabled',
|
||||
);
|
||||
unsubscribe();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('uses SSE fallback only when explicitly enabled', async () => {
|
||||
window.localStorage.setItem('clawx:allow-sse-fallback', '1');
|
||||
const { subscribeHostEvent } = await import('@/lib/host-events');
|
||||
const handler = vi.fn();
|
||||
const unsubscribe = subscribeHostEvent('unknown:event', handler);
|
||||
|
||||
expect(createHostEventSourceMock).toHaveBeenCalledTimes(1);
|
||||
expect(addEventListenerMock).toHaveBeenCalledWith('unknown:event', expect.any(Function));
|
||||
|
||||
const listener = addEventListenerMock.mock.calls[0][1] as (event: Event) => void;
|
||||
listener({ data: JSON.stringify({ x: 1 }) } as unknown as Event);
|
||||
expect(handler).toHaveBeenCalledWith({ x: 1 });
|
||||
|
||||
unsubscribe();
|
||||
expect(removeEventListenerMock).toHaveBeenCalledWith('unknown:event', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
89
tests/unit/provider-model-sync.test.ts
Normal file
89
tests/unit/provider-model-sync.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildNonOAuthAgentProviderUpdate, getModelIdFromRef } from '@electron/main/provider-model-sync';
|
||||
import type { ProviderConfig } from '@electron/utils/secure-storage';
|
||||
|
||||
function providerConfig(overrides: Partial<ProviderConfig>): ProviderConfig {
|
||||
return {
|
||||
id: 'provider-id',
|
||||
name: 'Provider',
|
||||
type: 'moonshot',
|
||||
enabled: true,
|
||||
createdAt: '2026-03-09T00:00:00.000Z',
|
||||
updatedAt: '2026-03-09T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('provider-model-sync', () => {
|
||||
it('extracts model ID from provider/model refs', () => {
|
||||
expect(getModelIdFromRef('moonshot/kimi-k2.5', 'moonshot')).toBe('kimi-k2.5');
|
||||
expect(getModelIdFromRef('kimi-k2.5', 'moonshot')).toBe('kimi-k2.5');
|
||||
expect(getModelIdFromRef(undefined, 'moonshot')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('builds models.json update payload for moonshot default switch', () => {
|
||||
const payload = buildNonOAuthAgentProviderUpdate(
|
||||
providerConfig({ type: 'moonshot', id: 'moonshot' }),
|
||||
'moonshot',
|
||||
'moonshot/kimi-k2.5',
|
||||
);
|
||||
|
||||
expect(payload).toEqual({
|
||||
providerKey: 'moonshot',
|
||||
entry: {
|
||||
baseUrl: 'https://api.moonshot.cn/v1',
|
||||
api: 'openai-completions',
|
||||
apiKey: 'MOONSHOT_API_KEY',
|
||||
models: [{ id: 'kimi-k2.5', name: 'kimi-k2.5' }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers provider custom baseUrl and omits models when modelRef is missing', () => {
|
||||
const payload = buildNonOAuthAgentProviderUpdate(
|
||||
providerConfig({
|
||||
type: 'ark',
|
||||
id: 'ark',
|
||||
baseUrl: 'https://custom-ark.example.com/v3',
|
||||
}),
|
||||
'ark',
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(payload).toEqual({
|
||||
providerKey: 'ark',
|
||||
entry: {
|
||||
baseUrl: 'https://custom-ark.example.com/v3',
|
||||
api: 'openai-completions',
|
||||
apiKey: 'ARK_API_KEY',
|
||||
models: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for oauth and multi-instance providers', () => {
|
||||
expect(
|
||||
buildNonOAuthAgentProviderUpdate(
|
||||
providerConfig({ type: 'qwen-portal', id: 'qwen-portal' }),
|
||||
'qwen-portal',
|
||||
'qwen-portal/coder-model',
|
||||
),
|
||||
).toBeNull();
|
||||
|
||||
expect(
|
||||
buildNonOAuthAgentProviderUpdate(
|
||||
providerConfig({ type: 'custom', id: 'custom-123' }),
|
||||
'custom-123',
|
||||
'custom-123/model',
|
||||
),
|
||||
).toBeNull();
|
||||
|
||||
expect(
|
||||
buildNonOAuthAgentProviderUpdate(
|
||||
providerConfig({ type: 'ollama', id: 'ollama' }),
|
||||
'ollama',
|
||||
'ollama/qwen3:latest',
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user