refactor(new merge) (#369)

Co-authored-by: paisley <8197966+su8su@users.noreply.github.com>
Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
This commit is contained in:
Lingxuan Zuo
2026-03-09 20:18:25 +08:00
committed by GitHub
Unverified
parent e28eba01e1
commit 3d664c017a
18 changed files with 514 additions and 390 deletions

View File

@@ -68,6 +68,16 @@ describe('api-client', () => {
expect(msg).toContain('Permission denied');
});
it('returns user-facing message for auth invalid error', () => {
const msg = toUserMessage(new AppError('AUTH_INVALID', 'Invalid Authentication'));
expect(msg).toContain('Authentication failed');
});
it('returns user-facing message for channel unavailable error', () => {
const msg = toUserMessage(new AppError('CHANNEL_UNAVAILABLE', 'Invalid IPC channel'));
expect(msg).toContain('Service channel unavailable');
});
it('falls back to legacy channel when unified route is unsupported', async () => {
const invoke = vi.mocked(window.electron.ipcRenderer.invoke);
invoke

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { AppError, mapBackendErrorCode, normalizeAppError } from '@/lib/error-model';
describe('error-model', () => {
it('maps backend UNSUPPORTED to CHANNEL_UNAVAILABLE', () => {
expect(mapBackendErrorCode('UNSUPPORTED')).toBe('CHANNEL_UNAVAILABLE');
});
it('normalizes auth errors into AUTH_INVALID', () => {
const error = normalizeAppError(new Error('HTTP 401: Invalid Authentication'));
expect(error.code).toBe('AUTH_INVALID');
});
it('normalizes ipc channel errors into CHANNEL_UNAVAILABLE', () => {
const error = normalizeAppError(new Error('Invalid IPC channel: hostapi:fetch'));
expect(error.code).toBe('CHANNEL_UNAVAILABLE');
});
it('preserves AppError and merges details', () => {
const base = new AppError('TIMEOUT', 'request timeout', undefined, { a: 1 });
const normalized = normalizeAppError(base, { b: 2 });
expect(normalized.code).toBe('TIMEOUT');
expect(normalized.details).toEqual({ a: 1, b: 2 });
});
});

View File

@@ -44,14 +44,27 @@ describe('host-api', () => {
expect(result.ok).toBe(1);
});
it('throws proxy error from unified envelope', async () => {
it('falls back to browser fetch when hostapi handler is not registered', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ fallback: true }),
});
vi.stubGlobal('fetch', fetchMock);
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');
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) }),
);
});
it('throws message from legacy non-ok envelope', async () => {

View File

@@ -0,0 +1,49 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const hostApiFetchMock = vi.fn();
const rpcMock = vi.fn();
vi.mock('@/lib/host-api', () => ({
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
}));
vi.mock('@/stores/gateway', () => ({
useGatewayStore: {
getState: () => ({
rpc: (...args: unknown[]) => rpcMock(...args),
}),
},
}));
describe('skills store error mapping', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
});
it('maps fetchSkills rate-limit error by AppError code', async () => {
rpcMock.mockResolvedValueOnce({ skills: [] });
hostApiFetchMock.mockRejectedValueOnce(new Error('rate limit exceeded'));
const { useSkillsStore } = await import('@/stores/skills');
await useSkillsStore.getState().fetchSkills();
expect(useSkillsStore.getState().error).toBe('fetchRateLimitError');
});
it('maps searchSkills timeout error by AppError code', async () => {
hostApiFetchMock.mockRejectedValueOnce(new Error('request timeout'));
const { useSkillsStore } = await import('@/stores/skills');
await useSkillsStore.getState().searchSkills('git');
expect(useSkillsStore.getState().searchError).toBe('searchTimeoutError');
});
it('maps installSkill timeout result into installTimeoutError', async () => {
hostApiFetchMock.mockResolvedValueOnce({ success: false, error: 'request timeout' });
const { useSkillsStore } = await import('@/stores/skills');
await expect(useSkillsStore.getState().installSkill('demo-skill')).rejects.toThrow('installTimeoutError');
});
});

View File

@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { IncomingMessage, ServerResponse } from 'http';
const getRecentTokenUsageHistoryMock = vi.fn();
const sendJsonMock = vi.fn();
vi.mock('@electron/utils/token-usage', () => ({
getRecentTokenUsageHistory: (...args: unknown[]) => getRecentTokenUsageHistoryMock(...args),
}));
vi.mock('@electron/api/route-utils', () => ({
sendJson: (...args: unknown[]) => sendJsonMock(...args),
}));
describe('handleUsageRoutes', () => {
beforeEach(() => {
vi.resetAllMocks();
});
it('passes undefined limit when query param is missing', async () => {
getRecentTokenUsageHistoryMock.mockResolvedValueOnce([{ totalTokens: 1 }]);
const { handleUsageRoutes } = await import('@electron/api/routes/usage');
const handled = await handleUsageRoutes(
{ method: 'GET' } as IncomingMessage,
{} as ServerResponse,
new URL('http://127.0.0.1:3210/api/usage/recent-token-history'),
{} as never,
);
expect(handled).toBe(true);
expect(getRecentTokenUsageHistoryMock).toHaveBeenCalledWith(undefined);
expect(sendJsonMock).toHaveBeenCalledWith(
expect.anything(),
200,
[{ totalTokens: 1 }],
);
});
it('passes sanitized numeric limit when provided', async () => {
getRecentTokenUsageHistoryMock.mockResolvedValueOnce([]);
const { handleUsageRoutes } = await import('@electron/api/routes/usage');
await handleUsageRoutes(
{ method: 'GET' } as IncomingMessage,
{} as ServerResponse,
new URL('http://127.0.0.1:3210/api/usage/recent-token-history?limit=50.9'),
{} as never,
);
expect(getRecentTokenUsageHistoryMock).toHaveBeenCalledWith(50);
});
});