feat: v0.4.1 质量加固版 — 工程化基线 + 安全加固 + 测试补齐 + 体验升级
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m25s
CI / 全量测试 (Electron ABI, experimental) (push) Failing after 5m19s
CI / 产物编译验证 (push) Successful in 10m3s

工程化(从零到一):
- 新增 Gitea Actions CI(debian-latest):类型检查 + Lint + 单元测试 + 产物编译验证
- 新增 husky + lint-staged 预提交钩子(lint-staged + typecheck 门禁)
- 移除坏脚本 test:e2e(无 Playwright 配置必失败);prebuild 改用内置 fs.rmSync
- 依赖清理:移除死依赖 sql.js(2MB)/@playwright/test,@types/shell-quote 移至 devDependencies

安全加固:
- PolicyEngine 频率限制按会话隔离(多会话并发不再互抢配额)
- ConfirmationHook 拒绝记忆加 10 分钟 TTL + 恢复询问入口(新增 2 个 IPC 通道)
- Windows run_command 白名单工具(git/node/npm/npx/pnpm/yarn/tsc)改走 cmd.exe /c + 参数数组执行,收窄 shell 注入面
- web_search 四引擎 HTML 解析迁移 node-html-parser(结构化主层 + 正则降级)

缺陷修复(测试驱动发现):
- mapError 大小写缺陷:网络错误码永远落入 UNKNOWN 无法触发重试
- 搜狗解析器自我过滤:相对链接补全后又被 sogou.com 过滤导致结果全丢
- 百度复合类名重复收录:class="result c-container" 被双重匹配

测试补齐(113 → 194 用例):
- 新增 5 个测试文件:sse-stream / base-adapter / confirmation-hook / ipc-agent 编排链路 / web-search 解析器
- 覆盖 sendMessage 全分支、SSE 流解析、错误映射、确认钩子竞态/超时/批量审批

体验升级:
- OutputValidator 验证结果可见化(VALIDATION 流事件 → 聊天流提示卡)
- SettingsModal 巨型组件拆分(1503 行 → 10 个文件,可独立维护)
- MessageList 接入 react-virtuoso 真虚拟滚动(千条消息恒定开销)
- MCP 新增 streamable HTTP 传输支持(SDK 内置传输 + DB 迁移 6 + UI 双模式)
This commit is contained in:
2026-08-21 13:58:48 +08:00
parent 2230bcec3f
commit 49c9b25538
41 changed files with 6254 additions and 2608 deletions
@@ -0,0 +1,245 @@
/**
* BaseAdapter 单元测试(v0.4.1 测试补齐)
* 覆盖:错误映射(mapError)、HTTP 错误识别(throwHttpError)、
* ContentFilterError、上下文窗口读取、fetchWithTimeout 超时与清理
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { BaseAdapter, ContentFilterError } from '../base-adapter';
import { MetonaErrorCode, MetonaStreamEventType } from '../../types';
import type {
IMetonaProviderAdapter,
AdapterConfig,
MetonaRequest,
MetonaResponse,
MetonaStreamEvent,
} from '../../types';
/** 测试用具体 Adapter 实现(暴露 protected 方法供测试) */
class TestAdapter extends BaseAdapter {
readonly providerId = 'test';
readonly supportedModels = ['test-model-a', 'test-model-b'];
readonly supportsToolCalling = true;
readonly supportsThinking = false;
async send(_request: MetonaRequest): Promise<MetonaResponse> {
throw new Error('not implemented');
}
async *sendStream(_request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
// 空实现
}
/** 测试辅助: 暴露 protected mapError */
mapErrorPublic(error: unknown) {
return this.mapError(error);
}
/** 测试辅助: 暴露 protected throwHttpError */
async throwHttpErrorPublic(response: Response, context: string) {
return this.throwHttpError(response, context);
}
/** 测试辅助: 暴露 protected fetchWithTimeout */
async fetchWithTimeoutPublic(url: string, init: RequestInit, timeoutMs: number) {
return this.fetchWithTimeout(url, init, timeoutMs);
}
}
function makeAdapter(config: Partial<AdapterConfig> = {}): TestAdapter {
return new TestAdapter({
provider: 'test',
baseURL: 'https://api.test.com',
apiKey: 'sk-test',
defaultModel: 'test-model-a',
...config,
});
}
describe('BaseAdapter — mapError 错误映射', () => {
it('timeout 消息映射为 NETWORK_TIMEOUT 且可重试', () => {
const adapter = makeAdapter();
const err = adapter.mapErrorPublic(new Error('Request timeout after 30s'));
expect(err.code).toBe(MetonaErrorCode.NETWORK_TIMEOUT);
expect(err.retryable).toBe(true);
expect(err.provider).toBe('test');
});
it('ECONNREFUSED 映射为 NETWORK_ERROR 且可重试', () => {
const adapter = makeAdapter();
const err = adapter.mapErrorPublic(new Error('fetch failed: ECONNREFUSED 127.0.0.1:11434'));
expect(err.code).toBe(MetonaErrorCode.NETWORK_ERROR);
expect(err.retryable).toBe(true);
});
it('HTTP 401 优先按 status code 映射为 AUTH_INVALID 且不可重试', () => {
const adapter = makeAdapter();
const e = new Error('API error: 401 Unauthorized');
(e as Error & { status: number }).status = 401;
const err = adapter.mapErrorPublic(e);
expect(err.code).toBe(MetonaErrorCode.AUTH_INVALID);
expect(err.retryable).toBe(false);
});
it('HTTP 429 映射为 RATE_LIMITED 且可重试', () => {
const adapter = makeAdapter();
const e = new Error('429 Too Many Requests');
(e as Error & { status: number }).status = 429;
const err = adapter.mapErrorPublic(e);
expect(err.code).toBe(MetonaErrorCode.RATE_LIMITED);
expect(err.retryable).toBe(true);
expect(err.retryAfterMs).toBe(5000);
});
it('ContentFilterError 优先映射为 CONTENT_FILTERED', () => {
const adapter = makeAdapter();
const cf = new ContentFilterError('high risk content', 'MiMo');
const err = adapter.mapErrorPublic(cf);
expect(err.code).toBe(MetonaErrorCode.CONTENT_FILTERED);
expect(err.retryable).toBe(false);
});
it('普通 Error 映射为 UNKNOWN 且不可重试', () => {
const adapter = makeAdapter();
const err = adapter.mapErrorPublic(new Error('whatever'));
expect(err.code).toBe(MetonaErrorCode.UNKNOWN);
expect(err.retryable).toBe(false);
});
});
describe('BaseAdapter — throwHttpError', () => {
function makeResponse(status: number, body: string): Response {
return new Response(body, { status, statusText: 'Status' });
}
it('content_filter 错误体抛出 ContentFilterError(含 status', async () => {
const adapter = makeAdapter();
const body = JSON.stringify({ error: { code: 'content_filter', message: 'high risk' } });
await expect(
adapter.throwHttpErrorPublic(makeResponse(400, body), 'MiMo'),
).rejects.toBeInstanceOf(ContentFilterError);
try {
await adapter.throwHttpErrorPublic(makeResponse(400, body), 'MiMo');
} catch (e) {
expect((e as ContentFilterError & { status: number }).status).toBe(400);
expect((e as ContentFilterError).message).toContain('安全审核拦截');
}
});
it('普通错误体抛出带 status 属性的 Error(供 isRetryableError 判断)', async () => {
const adapter = makeAdapter();
await expect(
adapter.throwHttpErrorPublic(makeResponse(503, 'Service Unavailable'), 'DeepSeek'),
).rejects.toThrow('DeepSeek: 503');
try {
await adapter.throwHttpErrorPublic(makeResponse(503, ''), 'DeepSeek');
} catch (e) {
expect((e as Error & { status: number }).status).toBe(503);
}
});
});
describe('BaseAdapter — getContextWindow', () => {
it('配置了 contextWindow 时返回配置值', () => {
const adapter = makeAdapter({ contextWindow: 128_000 });
expect(adapter.getContextWindow()).toBe(128_000);
});
it('未配置时返回保守默认值 1M', () => {
const adapter = makeAdapter();
expect(adapter.getContextWindow()).toBe(1_000_000);
});
});
describe('BaseAdapter — listModels / healthCheck', () => {
it('listModels 将 supportedModels 映射为 MetonaModelInfo', async () => {
const adapter = makeAdapter();
const models = await adapter.listModels();
expect(models.map((m) => m.id)).toEqual(['test-model-a', 'test-model-b']);
});
it('healthCheck 成功返回 true', async () => {
const adapter = makeAdapter();
expect(await adapter.healthCheck()).toBe(true);
});
});
describe('BaseAdapter — fetchWithTimeout', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('正常请求返回 Response 并清理 timer', async () => {
const adapter = makeAdapter();
const mockResponse = new Response('{"ok":true}');
const fetchMock = vi.fn().mockResolvedValue(mockResponse);
vi.stubGlobal('fetch', fetchMock);
const result = await adapter.fetchWithTimeoutPublic('https://api.test.com/v1/chat', {}, 5_000);
expect(result).toBe(mockResponse);
expect(fetchMock).toHaveBeenCalledOnce();
});
it('超时后 abort 请求(AbortError', async () => {
const adapter = makeAdapter();
vi.useFakeTimers();
const fetchMock = vi.fn(
(_url: string, init: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
init.signal?.addEventListener('abort', () =>
reject(new DOMException('Aborted', 'AbortError')),
);
}),
);
vi.stubGlobal('fetch', fetchMock);
const promise = adapter.fetchWithTimeoutPublic('https://api.test.com/v1/chat', {}, 100);
const expectation = expect(promise).rejects.toThrow('Aborted');
vi.advanceTimersByTime(150);
await expectation;
vi.useRealTimers();
});
it('外部 abort 信号触发请求中断', async () => {
const adapter = makeAdapter();
const controller = new AbortController();
adapter.setAbortSignal(controller.signal);
const fetchMock = vi.fn(
(_url: string, init: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
init.signal?.addEventListener('abort', () =>
reject(new DOMException('Aborted', 'AbortError')),
);
}),
);
vi.stubGlobal('fetch', fetchMock);
const promise = adapter.fetchWithTimeoutPublic('https://api.test.com/v1/chat', {}, 30_000);
const expectation = expect(promise).rejects.toThrow('Aborted');
controller.abort();
await expectation;
});
});
describe('BaseAdapter — 接口契约', () => {
it('providerId / 能力声明符合 IMetonaProviderAdapter 契约', () => {
const adapter: IMetonaProviderAdapter = makeAdapter();
expect(adapter.providerId).toBe('test');
expect(typeof adapter.send).toBe('function');
expect(typeof adapter.sendStream).toBe('function');
expect(typeof adapter.getContextWindow).toBe('function');
expect(typeof adapter.setAbortSignal).toBe('function');
});
it('sendStream 是 AsyncGenerator(可迭代)', async () => {
const adapter = makeAdapter();
const events: MetonaStreamEvent[] = [];
for await (const ev of adapter.sendStream({} as MetonaRequest)) {
events.push(ev);
}
expect(events).toHaveLength(0);
expect(MetonaStreamEventType.TEXT_DELTA).toBe('text_delta');
});
});