Optimize gateway comms reload behavior and strengthen regression coverage (#496)
This commit is contained in:
committed by
GitHub
Unverified
parent
08960d700f
commit
1dbe4a8466
@@ -244,4 +244,24 @@ describe('api-client', () => {
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result.channels[0].id).toBe('telegram-default');
|
||||
});
|
||||
|
||||
it('rejects invalid config.patch params before gateway:httpProxy call', async () => {
|
||||
const invoke = vi.mocked(window.electron.ipcRenderer.invoke);
|
||||
const invoker = createGatewayHttpTransportInvoker();
|
||||
|
||||
await expect(invoker('gateway:rpc', ['config.patch', 'abc'])).rejects.toThrow(
|
||||
'gateway:rpc config.patch requires object params',
|
||||
);
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects invalid config.patch.patch before gateway:httpProxy call', async () => {
|
||||
const invoke = vi.mocked(window.electron.ipcRenderer.invoke);
|
||||
const invoker = createGatewayHttpTransportInvoker();
|
||||
|
||||
await expect(invoker('gateway:rpc', ['config.patch', { patch: 'abc' }])).rejects.toThrow(
|
||||
'gateway:rpc config.patch requires object patch',
|
||||
);
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
62
tests/unit/comms-scripts.test.ts
Normal file
62
tests/unit/comms-scripts.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
aggregateMetrics,
|
||||
calculateScenarioMetrics,
|
||||
} from '../../scripts/comms/replay.mjs';
|
||||
import { evaluateReport } from '../../scripts/comms/compare.mjs';
|
||||
|
||||
function buildPassingScenarioMetrics() {
|
||||
return {
|
||||
duplicate_event_rate: 0,
|
||||
event_fanout_ratio: 1,
|
||||
history_inflight_max: 1,
|
||||
history_load_qps: 0.3,
|
||||
rpc_p50_ms: 100,
|
||||
rpc_p95_ms: 150,
|
||||
rpc_timeout_rate: 0,
|
||||
gateway_reconnect_count: 0,
|
||||
message_loss_count: 0,
|
||||
message_order_violation_count: 0,
|
||||
};
|
||||
}
|
||||
|
||||
describe('comms scripts', () => {
|
||||
it('computes scenario metrics with dedupe and inflight tracking', () => {
|
||||
const metrics = calculateScenarioMetrics([
|
||||
{ ts: 0, type: 'gateway_event', runId: 'r1', sessionKey: 's1', seq: 1, state: 'started', fanout: 1 },
|
||||
{ ts: 0.2, type: 'gateway_event', runId: 'r1', sessionKey: 's1', seq: 1, state: 'started', fanout: 1 },
|
||||
{ ts: 0.5, type: 'history_load', sessionKey: 's1', action: 'start' },
|
||||
{ ts: 0.7, type: 'history_load', sessionKey: 's1', action: 'end' },
|
||||
{ ts: 1.0, type: 'rpc', latencyMs: 120, timeout: false },
|
||||
{ ts: 1.5, type: 'message', lost: false, orderViolation: false },
|
||||
]);
|
||||
|
||||
expect(metrics.duplicate_event_rate).toBeCloseTo(0.5, 6);
|
||||
expect(metrics.history_inflight_max).toBe(1);
|
||||
expect(metrics.rpc_p95_ms).toBe(120);
|
||||
});
|
||||
|
||||
it('aggregates multiple scenario metrics deterministically', () => {
|
||||
const aggregate = aggregateMetrics([
|
||||
{ ...buildPassingScenarioMetrics(), rpc_p95_ms: 200 },
|
||||
{ ...buildPassingScenarioMetrics(), rpc_p95_ms: 400 },
|
||||
]);
|
||||
expect(aggregate.rpc_p95_ms).toBe(300);
|
||||
expect(aggregate.history_inflight_max).toBe(1);
|
||||
});
|
||||
|
||||
it('fails report evaluation when required scenarios are missing', () => {
|
||||
const passing = buildPassingScenarioMetrics();
|
||||
const current = {
|
||||
aggregate: passing,
|
||||
scenarios: {
|
||||
'happy-path-chat': passing,
|
||||
},
|
||||
};
|
||||
const baseline = { aggregate: passing };
|
||||
const result = evaluateReport(current, baseline);
|
||||
|
||||
expect(result.failures.some((f) => f.includes('missing scenario'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ vi.mock('@/lib/api-client', () => ({
|
||||
describe('host-api', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
window.localStorage.removeItem('clawx:allow-localhost-fallback');
|
||||
});
|
||||
|
||||
it('uses IPC proxy and returns unified envelope json', async () => {
|
||||
@@ -51,6 +52,7 @@ describe('host-api', () => {
|
||||
json: async () => ({ fallback: true }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
window.localStorage.setItem('clawx:allow-localhost-fallback', '1');
|
||||
|
||||
invokeIpcMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
@@ -86,6 +88,7 @@ describe('host-api', () => {
|
||||
json: async () => ({ fallback: true }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
window.localStorage.setItem('clawx:allow-localhost-fallback', '1');
|
||||
|
||||
invokeIpcMock.mockRejectedValueOnce(new Error('Invalid IPC channel: hostapi:fetch'));
|
||||
|
||||
@@ -98,4 +101,19 @@ describe('host-api', () => {
|
||||
expect.objectContaining({ headers: expect.any(Object) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not use localhost fallback when policy flag is disabled', 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');
|
||||
await expect(hostApiFetch('/api/test')).rejects.toThrow('Invalid IPC channel: hostapi:fetch');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
145
tests/unit/provider-runtime-sync.test.ts
Normal file
145
tests/unit/provider-runtime-sync.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { GatewayManager } from '@electron/gateway/manager';
|
||||
import type { ProviderConfig } from '@electron/utils/secure-storage';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getProviderAccount: vi.fn(),
|
||||
listProviderAccounts: vi.fn(),
|
||||
getProviderSecret: vi.fn(),
|
||||
getAllProviders: vi.fn(),
|
||||
getApiKey: vi.fn(),
|
||||
getDefaultProvider: vi.fn(),
|
||||
getProvider: vi.fn(),
|
||||
getProviderConfig: vi.fn(),
|
||||
getProviderDefaultModel: vi.fn(),
|
||||
removeProviderFromOpenClaw: vi.fn(),
|
||||
saveOAuthTokenToOpenClaw: vi.fn(),
|
||||
saveProviderKeyToOpenClaw: vi.fn(),
|
||||
setOpenClawDefaultModel: vi.fn(),
|
||||
setOpenClawDefaultModelWithOverride: vi.fn(),
|
||||
syncProviderConfigToOpenClaw: vi.fn(),
|
||||
updateAgentModelProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@electron/services/providers/provider-store', () => ({
|
||||
getProviderAccount: mocks.getProviderAccount,
|
||||
listProviderAccounts: mocks.listProviderAccounts,
|
||||
}));
|
||||
|
||||
vi.mock('@electron/services/secrets/secret-store', () => ({
|
||||
getProviderSecret: mocks.getProviderSecret,
|
||||
}));
|
||||
|
||||
vi.mock('@electron/utils/secure-storage', () => ({
|
||||
getAllProviders: mocks.getAllProviders,
|
||||
getApiKey: mocks.getApiKey,
|
||||
getDefaultProvider: mocks.getDefaultProvider,
|
||||
getProvider: mocks.getProvider,
|
||||
}));
|
||||
|
||||
vi.mock('@electron/utils/provider-registry', () => ({
|
||||
getProviderConfig: mocks.getProviderConfig,
|
||||
getProviderDefaultModel: mocks.getProviderDefaultModel,
|
||||
}));
|
||||
|
||||
vi.mock('@electron/utils/openclaw-auth', () => ({
|
||||
removeProviderFromOpenClaw: mocks.removeProviderFromOpenClaw,
|
||||
saveOAuthTokenToOpenClaw: mocks.saveOAuthTokenToOpenClaw,
|
||||
saveProviderKeyToOpenClaw: mocks.saveProviderKeyToOpenClaw,
|
||||
setOpenClawDefaultModel: mocks.setOpenClawDefaultModel,
|
||||
setOpenClawDefaultModelWithOverride: mocks.setOpenClawDefaultModelWithOverride,
|
||||
syncProviderConfigToOpenClaw: mocks.syncProviderConfigToOpenClaw,
|
||||
updateAgentModelProvider: mocks.updateAgentModelProvider,
|
||||
}));
|
||||
|
||||
vi.mock('@electron/utils/logger', () => ({
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
syncDefaultProviderToRuntime,
|
||||
syncDeletedProviderToRuntime,
|
||||
syncSavedProviderToRuntime,
|
||||
} from '@electron/services/providers/provider-runtime-sync';
|
||||
|
||||
function createProvider(overrides: Partial<ProviderConfig> = {}): ProviderConfig {
|
||||
return {
|
||||
id: 'moonshot',
|
||||
name: 'Moonshot',
|
||||
type: 'moonshot',
|
||||
model: 'kimi-k2.5',
|
||||
enabled: true,
|
||||
createdAt: '2026-03-14T00:00:00.000Z',
|
||||
updatedAt: '2026-03-14T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createGateway(state: 'running' | 'stopped' = 'running'): Pick<GatewayManager, 'debouncedReload' | 'debouncedRestart' | 'getStatus'> {
|
||||
return {
|
||||
debouncedReload: vi.fn(),
|
||||
debouncedRestart: vi.fn(),
|
||||
getStatus: vi.fn(() => ({ state } as ReturnType<GatewayManager['getStatus']>)),
|
||||
};
|
||||
}
|
||||
|
||||
describe('provider-runtime-sync refresh strategy', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getProviderAccount.mockResolvedValue(null);
|
||||
mocks.getProviderSecret.mockResolvedValue(undefined);
|
||||
mocks.getAllProviders.mockResolvedValue([]);
|
||||
mocks.getApiKey.mockResolvedValue('sk-test');
|
||||
mocks.getDefaultProvider.mockResolvedValue('moonshot');
|
||||
mocks.getProvider.mockResolvedValue(createProvider());
|
||||
mocks.getProviderDefaultModel.mockReturnValue('kimi-k2.5');
|
||||
mocks.getProviderConfig.mockReturnValue({
|
||||
api: 'openai-completions',
|
||||
baseUrl: 'https://api.moonshot.cn/v1',
|
||||
apiKeyEnv: 'MOONSHOT_API_KEY',
|
||||
});
|
||||
mocks.syncProviderConfigToOpenClaw.mockResolvedValue(undefined);
|
||||
mocks.setOpenClawDefaultModel.mockResolvedValue(undefined);
|
||||
mocks.setOpenClawDefaultModelWithOverride.mockResolvedValue(undefined);
|
||||
mocks.saveProviderKeyToOpenClaw.mockResolvedValue(undefined);
|
||||
mocks.removeProviderFromOpenClaw.mockResolvedValue(undefined);
|
||||
mocks.updateAgentModelProvider.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('uses debouncedReload after saving provider config', async () => {
|
||||
const gateway = createGateway('running');
|
||||
await syncSavedProviderToRuntime(createProvider(), undefined, gateway as GatewayManager);
|
||||
|
||||
expect(gateway.debouncedReload).toHaveBeenCalledTimes(1);
|
||||
expect(gateway.debouncedRestart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses debouncedRestart after deleting provider config', async () => {
|
||||
const gateway = createGateway('running');
|
||||
await syncDeletedProviderToRuntime(createProvider(), 'moonshot', gateway as GatewayManager);
|
||||
|
||||
expect(gateway.debouncedRestart).toHaveBeenCalledTimes(1);
|
||||
expect(gateway.debouncedReload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses debouncedReload after switching default provider when gateway is running', async () => {
|
||||
const gateway = createGateway('running');
|
||||
await syncDefaultProviderToRuntime('moonshot', gateway as GatewayManager);
|
||||
|
||||
expect(gateway.debouncedReload).toHaveBeenCalledTimes(1);
|
||||
expect(gateway.debouncedRestart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips refresh after switching default provider when gateway is stopped', async () => {
|
||||
const gateway = createGateway('stopped');
|
||||
await syncDefaultProviderToRuntime('moonshot', gateway as GatewayManager);
|
||||
|
||||
expect(gateway.debouncedReload).not.toHaveBeenCalled();
|
||||
expect(gateway.debouncedRestart).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -43,9 +43,27 @@ describe('Settings Store', () => {
|
||||
});
|
||||
|
||||
it('should unlock dev mode', () => {
|
||||
const invoke = vi.mocked(window.electron.ipcRenderer.invoke);
|
||||
invoke.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: { success: true },
|
||||
},
|
||||
});
|
||||
|
||||
const { setDevModeUnlocked } = useSettingsStore.getState();
|
||||
setDevModeUnlocked(true);
|
||||
|
||||
expect(useSettingsStore.getState().devModeUnlocked).toBe(true);
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
'hostapi:fetch',
|
||||
expect.objectContaining({
|
||||
path: '/api/settings/devModeUnlocked',
|
||||
method: 'PUT',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should persist launch-at-startup setting through host api', () => {
|
||||
|
||||
Reference in New Issue
Block a user