Co-authored-by: paisley <8197966+su8su@users.noreply.github.com> Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
238 lines
7.9 KiB
TypeScript
238 lines
7.9 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import {
|
|
invokeIpc,
|
|
invokeIpcWithRetry,
|
|
AppError,
|
|
toUserMessage,
|
|
configureApiClient,
|
|
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'] }],
|
|
});
|
|
clearTransportBackoff();
|
|
unregisterTransportInvoker('ws');
|
|
unregisterTransportInvoker('http');
|
|
});
|
|
|
|
it('forwards invoke arguments and returns result', async () => {
|
|
const invoke = vi.mocked(window.electron.ipcRenderer.invoke);
|
|
invoke.mockResolvedValueOnce({ ok: true, data: { ok: true } });
|
|
|
|
const result = await invokeIpc<{ ok: boolean }>('settings:getAll', { a: 1 });
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(invoke).toHaveBeenCalledWith(
|
|
'app:request',
|
|
expect.objectContaining({
|
|
module: 'settings',
|
|
action: 'getAll',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('normalizes timeout errors', async () => {
|
|
const invoke = vi.mocked(window.electron.ipcRenderer.invoke);
|
|
invoke.mockRejectedValueOnce(new Error('Gateway Timeout'));
|
|
|
|
await expect(invokeIpc('gateway:status')).rejects.toMatchObject({ code: 'TIMEOUT' });
|
|
});
|
|
|
|
it('retries once for retryable errors', async () => {
|
|
const invoke = vi.mocked(window.electron.ipcRenderer.invoke);
|
|
invoke
|
|
.mockResolvedValueOnce({ ok: false, error: { code: 'TIMEOUT', message: 'network timeout' } })
|
|
.mockResolvedValueOnce({ ok: true, data: { success: true } });
|
|
|
|
const result = await invokeIpcWithRetry<{ success: boolean }>('provider:list', [], 1);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(invoke).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('returns user-facing message for permission error', () => {
|
|
const msg = toUserMessage(new AppError('PERMISSION', 'forbidden'));
|
|
expect(msg).toContain('Permission denied');
|
|
});
|
|
|
|
it('falls back to legacy channel when unified route is unsupported', async () => {
|
|
const invoke = vi.mocked(window.electron.ipcRenderer.invoke);
|
|
invoke
|
|
.mockRejectedValueOnce(new Error('APP_REQUEST_UNSUPPORTED:settings.getAll'))
|
|
.mockResolvedValueOnce({ foo: 'bar' });
|
|
|
|
const result = await invokeIpc<{ foo: string }>('settings:getAll');
|
|
expect(result.foo).toBe('bar');
|
|
expect(invoke).toHaveBeenNthCalledWith(2, 'settings:getAll');
|
|
});
|
|
|
|
it('sends tuple payload for multi-arg unified requests', async () => {
|
|
const invoke = vi.mocked(window.electron.ipcRenderer.invoke);
|
|
invoke.mockResolvedValueOnce({ ok: true, data: { success: true } });
|
|
|
|
const result = await invokeIpc<{ success: boolean }>('settings:set', 'language', 'en');
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(invoke).toHaveBeenCalledWith(
|
|
'app:request',
|
|
expect.objectContaining({
|
|
module: 'settings',
|
|
action: 'set',
|
|
payload: ['language', 'en'],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('falls through ws/http and succeeds via ipc when advanced transports fail', async () => {
|
|
const invoke = vi.mocked(window.electron.ipcRenderer.invoke);
|
|
invoke.mockResolvedValueOnce({ ok: true, data: { ok: true } });
|
|
|
|
registerTransportInvoker('ws', async () => {
|
|
throw new Error('ws unavailable');
|
|
});
|
|
registerTransportInvoker('http', async () => {
|
|
throw new Error('http unavailable');
|
|
});
|
|
configureApiClient({
|
|
enabled: { ws: true, http: true },
|
|
rules: [{ matcher: 'gateway:rpc', order: ['ws', 'http', 'ipc'] }],
|
|
});
|
|
|
|
const result = await invokeIpc<{ ok: boolean }>('gateway:rpc', 'chat.history', {});
|
|
expect(result.ok).toBe(true);
|
|
expect(invoke).toHaveBeenCalledWith('gateway:rpc', 'chat.history', {});
|
|
});
|
|
|
|
it('backs off failed ws transport and skips it on immediate retry', async () => {
|
|
const invoke = vi.mocked(window.electron.ipcRenderer.invoke);
|
|
invoke.mockResolvedValue({ ok: true });
|
|
const wsInvoker = vi.fn(async () => {
|
|
throw new Error('ws unavailable');
|
|
});
|
|
|
|
registerTransportInvoker('ws', wsInvoker);
|
|
configureApiClient({
|
|
enabled: { ws: true, http: false },
|
|
rules: [{ matcher: 'gateway:rpc', order: ['ws', 'ipc'] }],
|
|
});
|
|
|
|
await invokeIpc('gateway:rpc', 'chat.history', {});
|
|
await invokeIpc('gateway:rpc', 'chat.history', {});
|
|
|
|
expect(wsInvoker).toHaveBeenCalledTimes(1);
|
|
expect(invoke).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('retries ws transport after backoff is cleared', async () => {
|
|
const invoke = vi.mocked(window.electron.ipcRenderer.invoke);
|
|
invoke.mockResolvedValue({ ok: true });
|
|
const wsInvoker = vi.fn(async () => {
|
|
throw new Error('ws unavailable');
|
|
});
|
|
|
|
registerTransportInvoker('ws', wsInvoker);
|
|
configureApiClient({
|
|
enabled: { ws: true, http: false },
|
|
rules: [{ matcher: 'gateway:rpc', order: ['ws', 'ipc'] }],
|
|
});
|
|
|
|
await invokeIpc('gateway:rpc', 'chat.history', {});
|
|
clearTransportBackoff('ws');
|
|
await invokeIpc('gateway:rpc', 'chat.history', {});
|
|
|
|
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');
|
|
});
|
|
});
|