Files
metona-ai-desktop/electron/harness/adapters/__tests__/base-adapter.test.ts
T
thzxx 99d0c54129
CI / 类型检查 + Lint + 单元测试 (push) Failing after 6m27s
CI / 产物编译验证 (push) Successful in 9m57s
CI / 全量测试 (Electron ABI) (push) Failing after 5m19s
feat: v0.7.4 时序语义修正 · 防线实效补漏 · 全量测试翻倍 — 2406 用例 + jsdom 组件测试全量回归
P1 修复面收口:
- 超时三态区分(aborted→USER_INTERRUPT / ETIMEDOUT→TIMEOUT / 其余→ERROR),
  根治"真实网络超时被误报为用户中断"
- 流空闲超时统一(SSE/Ollama/Anthropic 读循环 60s 无数据抛 504 进重试通道)
- 同会话并发 sendMessage 防重入(isRunning 守卫)+ 会话存在性预检 +
  前置调用移入 try(ERROR+DONE 双事件保证,根治 isStreaming 假死)
- 清空审计后 resetChainCache(根治 verifyChain 误报 TAMPERED)
- DONE 不再提前清理 TRACE(TERMINATED 统一收尾,补全最终迭代录制)
- IME 合成回车不发送(普通 Enter + Cmd/Ctrl+Enter 双分支)+ handleSend 闭包修复

P2 安全纵深:
- preload 移除原始 electronAPI 暴露(渲染层零使用,关掉 XSS invoke 任意通道单点风险)
- CORS 同源回显根治(仅当前浏览页面 Origin,did-navigate 同步)
- MEMORY.md 命令保护正则扩展(括号/$/反引号/< 重定向边界 + 前导路径)
- write_file append TOCTOU 统一(open 后 realpath 校验,新文件分支补漏)
- 敏感键归一化(authKey 驼峰/连字符命中)+ MCP headers 鉴权值加密落库
- ReDoS 检测共享化(search_files/file_editor 统一拦截)
- run_tests/lint_code 升风险 + 需确认 + npx --no-install(执行边界对齐 run_command)
- MCP/SearXNG/llm.baseURL/updateFeedUrl 配置类 URL 高危目标校验(IPv6 去括号 +
  十六进制映射解析 + 尾点剥离)

P3 架构还债:
- temperature/maxTokens 热生效(引擎/编排器/SubAgent 三处接线)+ setBatch 单事务落盘
- SessionRecorder flush 竞态根治(flushPromise 等待 + 超限内联落盘 + stopRecording async)
- 内存收口(lastConsolidationBySession LRU / subTraces 清理 / 会话删除 disposeEngine)
- i18n 全量收口(28 组件 + 353 key 双字典,状态标签改渲染时函数)
- 死代码清理(updateTraceStep/HEADER_HEIGHT/void preA/失实注释)
- 斜杠菜单 MUI 化 + 删除逻辑收敛 resetSessionState + Blob URL 统一释放 +
  用户消息"仅保存"落库(saveMessage 透传前端 id 修复 id 错位)

P4 能力演进:
- 死循环检测拆分(驻留前置 + 乒乓后置带进度信号,合法交替不误报)
- run-lock 30s 超时强制 abort(旧 run 卡死不无限排队)
- RETRY 双通道 stream_reset(前端按 run 归属精确清空,根治重试文本重复)
- FTS5 trigram 中文子串搜索(迁移 9 版本化 SCHEMA_VERSION=2,≤2 字符 LIKE 回退)
- getContextWindow 兜底 1M→128K(未知模型防 413)

测试:
- 855 → 2406 用例(+1551,2.8 倍):服务层 +325(含 MemoryManager 51 新用例)、
  工具实体 +483、IPC/适配器 +390(含 OpenAI/Anthropic/Ollama 独立套件)、
  纯函数表格化 +330;引入 jsdom + @testing-library(14 组件测试文件 249 用例)
- 修复 R1(saveMessage id 透传)/ R2(stream_reset 精确归属)两个回归缺陷
- 遗留低危项清零:git-tools 顺序耦合 / web-fetch 真实时间退避 / slo 内存断言 /
  mcp-security 多余 skipIf / deepseek-balance 命名误导 / 组件 mock 注入脆弱性

版本: 0.7.4; README 同步(工具风险表/版本徽章); 依赖: 移除 @electron-toolkit/preload,
新增 jsdom/@testing-library(devDependencies 不打包)

回归: typecheck 双端 0 错误; ESLint 0/0; Electron ABI 全量 2406/2406 零跳过;
系统 Node 2110 通过 296 跳过(better-sqlite3 ABI)
2026-08-30 19:19:07 +08:00

473 lines
18 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);
});
// v0.7.4 P4-5: 兜底从 1M 降至 128K(未知模型按最保守主流窗口预算,防 413)
it('未配置时返回兜底默认值 128K(子类应覆盖真实窗口)', () => {
const adapter = makeAdapter();
expect(adapter.getContextWindow()).toBe(128_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');
});
});
// ===== 追加:fetchWithTimeout 更多边界 =====
describe('BaseAdapter — fetchWithTimeout 补充边界', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
vi.useRealTimers();
});
function hangingFetch(signalKey = 'signal'): ReturnType<typeof vi.fn> {
return vi.fn(
(_url: string, init: RequestInit) =>
new Promise<Response>((resolve, reject) => {
const signal = init[signalKey as 'signal'];
// 模拟真实 fetch:信号已提前 abort 时同步拒绝
if (signal?.aborted) {
reject(new DOMException('This operation was aborted', 'AbortError'));
return;
}
signal?.addEventListener('abort', () =>
reject(new DOMException('This operation was aborted', 'AbortError')),
);
}),
);
}
it('底层网络错误(非超时非外部 abort)原样抛出', async () => {
const adapter = makeAdapter();
const fetchMock = vi.fn().mockRejectedValue(new Error('socket hang up'));
vi.stubGlobal('fetch', fetchMock);
await expect(
adapter.fetchWithTimeoutPublic('https://api.test.com/v1/chat', {}, 5_000),
).rejects.toThrow('socket hang up');
});
it('外部信号已提前 abort → 直接 AbortError,不注册监听', async () => {
const adapter = makeAdapter();
const controller = new AbortController();
controller.abort();
adapter.setAbortSignal(controller.signal);
const addListenerSpy = vi.spyOn(controller.signal, 'addEventListener');
const fetchMock = hangingFetch();
vi.stubGlobal('fetch', fetchMock);
await adapter
.fetchWithTimeoutPublic('https://api.test.com/v1/chat', {}, 30_000)
.catch((e: Error) => {
expect(e.name).toBe('AbortError');
});
// 已 aborted 的信号走 controller.abort() 分支,不再注册监听
expect(addListenerSpy).not.toHaveBeenCalled();
});
it('外部 abort 后 listener 被移除(无监听泄漏)', async () => {
const adapter = makeAdapter();
const controller = new AbortController();
adapter.setAbortSignal(controller.signal);
const removeListenerSpy = vi.spyOn(controller.signal, 'removeEventListener');
const fetchMock = hangingFetch();
vi.stubGlobal('fetch', fetchMock);
const promise = adapter.fetchWithTimeoutPublic('https://api.test.com/v1/chat', {}, 30_000);
controller.abort();
await promise.catch(() => undefined);
expect(removeListenerSpy).toHaveBeenCalled();
});
it('自身超时但外部信号同时触发 → 外部 abort 优先,抛 AbortError 而非 ETIMEDOUT', async () => {
const adapter = makeAdapter();
const controller = new AbortController();
adapter.setAbortSignal(controller.signal);
vi.useFakeTimers();
const fetchMock = hangingFetch();
vi.stubGlobal('fetch', fetchMock);
const promise = adapter.fetchWithTimeoutPublic('https://api.test.com/v1/chat', {}, 100);
const assertion = expect(promise).rejects.toSatisfy((e: Error & { code?: string }) => {
expect(e.name).toBe('AbortError');
expect(e.code).not.toBe('ETIMEDOUT');
return true;
});
controller.abort();
await vi.advanceTimersByTimeAsync(150);
await assertion;
});
it('请求成功后 timer 已清理(fake timers 验证无残留定时器)', async () => {
const adapter = makeAdapter();
vi.useFakeTimers();
const fetchMock = vi.fn().mockResolvedValue(new Response('{"ok":true}'));
vi.stubGlobal('fetch', fetchMock);
await adapter.fetchWithTimeoutPublic('https://api.test.com/v1/chat', {}, 5_000);
expect(vi.getTimerCount()).toBe(0);
});
it('超时后 timer 已清理(无泄漏)', async () => {
const adapter = makeAdapter();
vi.useFakeTimers();
const fetchMock = hangingFetch();
vi.stubGlobal('fetch', fetchMock);
const promise = adapter.fetchWithTimeoutPublic('https://api.test.com/v1/chat', {}, 100);
const assertion = expect(promise).rejects.toMatchObject({ code: 'ETIMEDOUT' });
await vi.advanceTimersByTimeAsync(150);
await assertion;
expect(vi.getTimerCount()).toBe(0);
});
});
// ===== 追加:throwHttpError 错误体解析矩阵 =====
describe('BaseAdapter — throwHttpError 错误体解析', () => {
function makeResponse(status: number, body: string): Response {
return new Response(body, { status, statusText: 'Status' });
}
it('OpenAI 格式 {error:{code,message}} 携带 code 时识别 content_filter(大小写不敏感)', async () => {
const adapter = makeAdapter();
for (const code of ['content_filter', 'Content_Filter', 'CONTENT_FILTER']) {
const body = JSON.stringify({ error: { code, message: 'high risk' } });
await expect(
adapter.throwHttpErrorPublic(makeResponse(400, body), 'Ctx'),
).rejects.toBeInstanceOf(ContentFilterError);
}
});
it('type 字段承载错误码时同样识别 content_filterMiMo 兼容)', async () => {
const adapter = makeAdapter();
const body = JSON.stringify({ error: { type: 'content_filter', message: 'blocked' } });
const err = await adapter
.throwHttpErrorPublic(makeResponse(403, body), 'MiMo')
.catch((e: unknown) => e);
expect(err).toBeInstanceOf(ContentFilterError);
expect((err as ContentFilterError).providerMessage).toBe('blocked');
});
it('content_filter 无 message → 默认提示文案', async () => {
const adapter = makeAdapter();
const body = JSON.stringify({ error: { code: 'content_filter' } });
const err = await adapter
.throwHttpErrorPublic(makeResponse(400, body), 'Ctx')
.catch((e: unknown) => e);
expect((err as ContentFilterError).providerMessage).toBe('内容触发安全过滤策略');
});
it('顶层 error 结构(无嵌套)识别 content_filter', async () => {
const adapter = makeAdapter();
const body = JSON.stringify({ code: 'content_filter', message: 'rejected' });
const err = await adapter
.throwHttpErrorPublic(makeResponse(400, body), 'Ctx')
.catch((e: unknown) => e);
expect(err).toBeInstanceOf(ContentFilterError);
});
it('非 JSON 错误体 → 普通 Error 携带 status', async () => {
const adapter = makeAdapter();
const err = await adapter
.throwHttpErrorPublic(makeResponse(502, '<html>bad gateway</html>'), 'GW')
.catch((e: unknown) => e);
expect(err).not.toBeInstanceOf(ContentFilterError);
expect((err as Error & { status?: number }).status).toBe(502);
expect((err as Error).message).toContain('<html>bad gateway</html>');
});
it('错误体恰好 500 字符不截断;超过 500 字符截断并标注原长度', async () => {
const adapter = makeAdapter();
const exactly500 = 'x'.repeat(500);
const err500 = await adapter
.throwHttpErrorPublic(makeResponse(500, exactly500), 'Ctx')
.catch((e: unknown) => e);
expect((err500 as Error).message).toContain(exactly500);
expect((err500 as Error).message).not.toContain('[truncated');
const over = 'x'.repeat(10_000);
const errOver = await adapter
.throwHttpErrorPublic(makeResponse(500, over), 'Ctx')
.catch((e: unknown) => e);
expect((errOver as Error).message).toContain('[truncated 10000 chars]');
expect((errOver as Error).message.length).toBeLessThan(1_000);
});
it('错误体为响应头 JSON 但带尾随空白 → 仍能解析(trim 后 JSON.parse 成功路径)', async () => {
const adapter = makeAdapter();
const body = `{"error":{"code":"content_filter","message":"blocked"}} `;
const err = await adapter
.throwHttpErrorPublic(makeResponse(400, body), 'Ctx')
.catch((e: unknown) => e);
// response.text() 返回原文,JSON.parse 对尾随空白容忍 → 走 content_filter 分支
expect(err).toBeInstanceOf(ContentFilterError);
});
it('statusText 拼接进错误消息', async () => {
const adapter = makeAdapter();
const res = new Response('', { status: 429, statusText: 'Too Many Requests' });
const err = await adapter.throwHttpErrorPublic(res, 'Ctx').catch((e: unknown) => e);
expect((err as Error).message).toContain('429 Too Many Requests');
});
});
// ===== 追加:getContextWindow / listModels / healthCheck =====
describe('BaseAdapter — getContextWindow 回退链', () => {
it('contextWindow=0 视为未配置(需 >0', () => {
const adapter = makeAdapter({ contextWindow: 0 });
expect(adapter.getContextWindow()).toBe(128_000);
});
it('contextWindow 为负数视为未配置', () => {
const adapter = makeAdapter({ contextWindow: -1 });
expect(adapter.getContextWindow()).toBe(128_000);
});
});
describe('BaseAdapter — listModels 默认映射与 healthCheck', () => {
it('listModels 映射保留默认模型顺序', async () => {
const adapter = makeAdapter();
const models = await adapter.listModels();
expect(models).toHaveLength(2);
expect(models[0]).toEqual({ id: 'test-model-a' });
expect(models[1]).toEqual({ id: 'test-model-b' });
});
it('healthCheck 在 listModels 抛错时返回 false', async () => {
const adapter = makeAdapter();
vi.spyOn(adapter, 'listModels').mockRejectedValue(new Error('API down'));
expect(await adapter.healthCheck()).toBe(false);
});
});