Files
metona-ai-desktop/electron/harness/adapters/__tests__/base-adapter.test.ts
T
thzxx 3940716dc2
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m45s
CI / 全量测试 (Electron ABI) (push) Failing after 5m22s
CI / 产物编译验证 (push) Successful in 10m3s
feat: v0.7.0 四阶段全量迭代 — 修复面收口 · 安全纵深 · 架构还债 · 能力演进
P1 修复面收口: v0.6.3 截断自愈推全量(Anthropic/Ollama/非流式/引擎兜底); SSE 上游错误帧检测进重试通道;
clearMessages 摘要游标根治; truncateResult 内联图片白名单统一; 前端四 bug(确认弹窗锁死/MemoryViewer/
Virtuoso Footer/abort 尾部过滤) + reasoning 缓冲跨迭代污染; 托盘通知过滤与新建会话死链接线

P2 安全纵深: MCP 审批闭环(ConfirmationHook×PolicyEngine 联动+重名拒注册); SSRF 收敛 ssrf-guard 共享模块
(web_fetch 双通道校验+重定向终态复检); Electron 加固(preload CJS 化→sandbox:true/CSP/权限白名单/will-navigate);
run_command cmd.exe 白名单通道元字符守门; diff_viewer 10MB 预检; Anthropic thinking 预算下限; Agnes 思考显式关闭

P3 架构还债: OpenAICompatibleAdapter 中间基类收敛四家样板; 错误分类单轨化(删 mapError/getFetchSignal,
超时显式 ETIMEDOUT); PRAGMA user_version 迁移版本化; 死代码清理专项(cn.ts/SHORTCUTS/ContextMenu 分支/
getWindowState/modifiedArgs/sandbox 空壳); i18next 引入; a11y 第一轮; SearXNG 页批量草稿模型统一

P4 能力演进: Ollama pull 可取消/capabilities 探测/num_ctx 实测缓存; UpdateService feed 比对式自动更新
(app:updateCheck IPC + StatusBar 入口); MiMo providerOptions(web_search 服务端工具/strict JSON);
web_fetch extract_mode=markdown(turndown); network.proxyUrl 全局代理(Chromium sessions+undici dispatcher)

测试: 264 → 507 用例(Electron ABI 全绿零跳过), 覆盖引擎压缩管线/重试竞速/MEMORY.md 闸门/file_editor 五操作/
filesystem 七工具实体夹具/git 真实仓库/SSE 错误帧/全线截断自愈/Provider 请求形态矩阵/SSRF 表测/钩子分级矩阵/
OutputValidator 全量/SLO 指标/MCP 安全纯函数/task_manager 链路/渲染层纯域/i18n 桥契约
2026-08-27 17:06:58 +08:00

241 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* BaseAdapter 单元测试(v0.6.4 P3-2 错误分类单轨化后重写)
*
* 契约变更说明:
* - mapError 已删除(生产路径死代码,与 engine.isRetryableError 双轨漂移)。
* 错误分类的唯一事实来源是 engine.isRetryableError —— 本文件改为验证
* "BaseAdapter 抛出的错误携带可判定字段"的形状契约:
* throwHttpError → error.statusfetchWithTimeout 超时 → code='ETIMEDOUT'
* + 'timed out' message(命中引擎网络超时分支)。
* - 新增:错误体长度截断、外部 abort 与自身超时的区分。
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { BaseAdapter, ContentFilterError } from '../base-adapter';
import { 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 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,
});
}
// ===== 错误形状契约(engine.isRetryableError 的输入保证) =====
describe('BaseAdapter — 抛出错误的可判定形状(单轨化契约)', () => {
/** 与 engine.isRetryableError 相同的判定逻辑(镜像断言用) */
const isRetryableShape = (err: unknown): boolean => {
const e = err as { status?: number; code?: string; message?: string };
if (e.status === 429) return true;
if (e.status && e.status >= 500 && e.status < 600) return true;
if (e.code === 'ECONNRESET' || e.code === 'ETIMEDOUT' || e.code === 'ENOTFOUND') return true;
const msg = e.message?.toLowerCase() ?? '';
if (msg.includes('aborted') || msg.includes('socket hang up')) return true;
return false;
};
function makeResponse(status: number, body: string): Response {
return new Response(body, { status, statusText: 'Status' });
}
it('throwHttpError 携带 status429/5xx 形状可重试,4xx 不可', async () => {
const adapter = makeAdapter();
try {
await adapter.throwHttpErrorPublic(makeResponse(429, 'rate limited'), 'T');
expect.fail('should throw');
} catch (e) {
expect((e as Error & { status?: number }).status).toBe(429);
expect(isRetryableShape(e)).toBe(true);
}
try {
await adapter.throwHttpErrorPublic(makeResponse(401, ''), 'T');
expect.fail('should throw');
} catch (e) {
expect((e as Error & { status?: number }).status).toBe(401);
expect(isRetryableShape(e)).toBe(false);
}
});
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('巨大 HTML 错误体在消息中被截断(v0.6.4', async () => {
const adapter = makeAdapter();
const bigHtml = `<html>${'x'.repeat(100_000)}</html>`;
let caught: unknown;
try {
await adapter.throwHttpErrorPublic(makeResponse(502, bigHtml), 'GW');
expect.fail('should throw');
} catch (e) {
caught = e;
}
expect((caught as Error).message.length).toBeLessThan(1_000);
expect((caught as Error).message).toContain('[truncated');
// 截断不影响 status 判定
expect((caught as Error & { status?: number }).status).toBe(502);
});
});
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 — fetchWithTimeoutP3-2 超时分类单轨化)', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
vi.useRealTimers();
});
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('自身超时 → 显式 ETIMEDOUT + timed out 消息(命中引擎网络超时可重试分支)', 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('This operation was aborted', 'AbortError')),
);
}),
);
vi.stubGlobal('fetch', fetchMock);
const promise = adapter.fetchWithTimeoutPublic('https://api.test.com/v1/chat', {}, 100);
const expectation = expect(promise).rejects.toSatisfy((e: Error & { code?: string }) => {
expect(e.code).toBe('ETIMEDOUT');
expect(e.message).toContain('timed out after 100ms');
// 关键:消息不再是裸 "Aborted" —— 引擎按网络超时(而非碰巧可重试)分类
return true;
});
vi.advanceTimersByTime(150);
await expectation;
vi.useRealTimers();
});
it('外部 abort(用户中断)→ 原样 AbortError,不被改写为超时', 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('This operation was aborted', 'AbortError')),
);
}),
);
vi.stubGlobal('fetch', fetchMock);
const promise = adapter.fetchWithTimeoutPublic('https://api.test.com/v1/chat', {}, 30_000);
controller.abort();
await promise.catch((e: Error & { code?: string }) => {
expect(e.name).toBe('AbortError');
// 未被转译为字符串型 ETIMEDOUT(注意 Node DOMException 自带数字 code=20
expect(e.code).not.toBe('ETIMEDOUT');
expect(e.message).not.toContain('timed out after');
});
});
});
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');
});
});