feat: v0.8.2 安全纵深补全 · 协议保真 · 断链修复 — 图片SSRF/根MEMORY.md保护根治 · Anthropic thinking回传+pause_turn续传 · 2523 用例全量回归 + E2E 扩充
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m45s
CI / 全量测试 (Electron ABI) (push) Failing after 6m28s
CI / 产物编译验证 (push) Successful in 11m18s

This commit is contained in:
2026-09-08 14:30:27 +08:00
parent 69776e447f
commit 4cd6e997b5
86 changed files with 4303 additions and 956 deletions
+70
View File
@@ -0,0 +1,70 @@
/**
* E2E 测试公共设施(v0.8.2 P3-3
*
* 封装"启动应用(隔离 userData+ 本地 mock LLM + 清理"的完整生命周期。
* 每个测试文件独立调用 launchApp() —— 应用实例与 mock 互相隔离,杜绝跨用例
* 的运行态竞态(中断链路对"上一 run 的收尾兜底定时器"这类跨用例污染敏感)。
*/
import type { ElectronApplication, Page } from '@playwright/test';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { _electron as electron } from 'playwright';
import { startMockLLM, type MockLLMHandle } from './mock-llm';
export interface AppHarness {
electronApp: ElectronApplication;
page: Page;
mock: MockLLMHandle;
userDataDir: string;
workspaceDir: string;
/** 关闭应用 + mock + 清理临时目录 */
cleanup: () => Promise<void>;
}
export async function launchApp(): Promise<AppHarness> {
// 1. 本地 mock Provider(随机端口,仅监听 127.0.0.1
const mock = await startMockLLM();
// 2. 隔离的用户数据与工作空间目录
const userDataDir = mkdtempSync(join(tmpdir(), 'metona-e2e-user-'));
const workspaceDir = join(userDataDir, 'workspace');
mkdirSync(workspaceDir, { recursive: true });
// workspace-config.json 指向隔离工作空间(main.ts 启动时优先读取)
writeFileSync(
join(userDataDir, 'workspace-config.json'),
JSON.stringify({ workspacePath: workspaceDir }),
'utf-8',
);
// 3. 启动 Electron(产物构建由 npm run test:e2e 的 build 步骤保证)
const electronApp = await electron.launch({
args: ['.'],
env: {
...process.env,
METONA_USER_DATA_DIR: userDataDir,
METONA_E2E_SEED_CONFIG: '1',
METONA_E2E_LLM_URL: mock.url,
// 隔离冒烟环境无更新源(生产环境 updater 由 feedUrl 空值禁用,双保险)
METONA_E2E_DISABLE_UPDATE: '1',
} as Record<string, string>,
});
const page = await electronApp.firstWindow();
await page.waitForLoadState('domcontentloaded');
const cleanup = async (): Promise<void> => {
await electronApp.close();
await mock.close();
// 调试期保留 userDatamain.log 排查用);稳定后恢复清理
if (!process.env['METONA_E2E_KEEP_USER_DATA']) {
try {
rmSync(userDataDir, { recursive: true, force: true });
} catch {
/* Windows 文件句柄释放延迟 — 清理失败不影响断言 */
}
}
};
return { electronApp, page, mock, userDataDir, workspaceDir, cleanup };
}
+63
View File
@@ -0,0 +1,63 @@
/**
* MetonaAI Desktop — E2E 中断链路(v0.8.2 P3-3
*
* 独立应用实例(独立 userData + mock),杜绝跨用例运行态竞态:
* 发送挂起脚本(mock 首帧后保持连接、心跳保活)→ 流式态出现中断按钮 →
* 点击中断 → 恢复 idle → 再次发送正常消息可完成(恢复力闭环)。
*/
import { expect, test } from '@playwright/test';
import { launchApp, type AppHarness } from './app-harness';
import { HANG_PREFIX, MOCK_REPLY } from './mock-llm';
let harness: AppHarness;
test.beforeAll(async () => {
harness = await launchApp();
});
test.afterAll(async () => {
await harness?.cleanup();
});
test('中断:挂起的流式回复 → 中断 → 恢复 idle → 可继续对话', async () => {
const page = harness.page;
const input = page.locator('[data-chat-input] textarea').first();
await expect(input).toBeVisible({ timeout: 30_000 });
// 发送挂起脚本:mock 推送首帧后保持连接(心跳保活,不会自然结束)
await input.click();
await input.fill('请执行 __E2E_HANG__ 脚本');
await input.press('Enter');
// 流式态:挂起脚本的首帧文本可见
await expect(page.getByText(HANG_PREFIX).first()).toBeVisible({ timeout: 30_000 });
// 中断按钮出现(发送按钮在流式态变为「中断」)
const stopButton = page.getByRole('button', { name: /中断|Stop/ }).first();
await expect(stopButton).toBeVisible({ timeout: 15_000 });
await stopButton.click();
// 中断后恢复 idle:输入框重新可用
await expect(input).toBeEnabled({ timeout: 15_000 });
// 恢复力闭环:中断后可继续正常对话(新 run → mock 正常回复)。
// abort 点击后引擎需短暂时间真正终止(waitForAbort),期间的发送会被
// 同会话防重入拒绝(busy 提示)—— 以"忙则等待重试"保证确定性。
const busyHint = page.getByText(/该会话正在执行任务|already running/).first();
let sent = false;
for (let attempt = 0; attempt < 10 && !sent; attempt++) {
await input.click();
await input.fill('中断后请再回复一次');
await input.press('Enter');
try {
await busyHint.waitFor({ state: 'visible', timeout: 1_500 });
await page.waitForTimeout(1_000);
} catch {
sent = true; // 未出现 busy 提示 → 本次发送已被接受
}
}
expect(sent).toBe(true);
// 独立实例中首条 MOCK_REPLY 即恢复对话的回答
await expect(page.getByText(MOCK_REPLY).first()).toBeVisible({ timeout: 30_000 });
});
+39 -55
View File
@@ -1,77 +1,36 @@
/**
* MetonaAI Desktop — E2E 冒烟测试(v0.8.1 P2-5
* MetonaAI Desktop — E2E 冒烟测试(v0.8.1 P2-5 引入,v0.8.2 P3-3 扩充
*
* 端到端链路(Playwright + Electron):
* 启动应用(隔离 userData)→ 跳过引导(种子配置)→ 新建会话 → 输入消息 →
* 发送 → 引擎调用本地 mock LLM(SSE 流式)→ 断言回复渲染 + mock 收到合法
* 请求体(携带「最大输出上限」设置值)→ 中断按钮恢复 idle
* 请求体(携带「最大输出上限」设置值)+ 多轮工具调用闭环
*
* 中断链路见 interrupt.spec.ts(独立应用实例,杜绝跨用例运行态竞态)。
*
* 契约:不触外网(LLM 指向 127.0.0.1 随机端口 mock)、不污染真实用户数据
* METONA_USER_DATA_DIR 重定向)、断言的配置值即设置面板合法配置项。
*/
import { expect, test, type ElectronApplication, type Page } from '@playwright/test';
import { _electron as electron } from 'playwright';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { startMockLLM, MOCK_REPLY, type MockLLMHandle } from './mock-llm';
import { expect, test } from '@playwright/test';
import { launchApp, type AppHarness } from './app-harness';
import { MOCK_REPLY, TOOL_REPLY } from './mock-llm';
let electronApp: ElectronApplication;
let page: Page;
let mock: MockLLMHandle;
let userDataDir: string;
let workspaceDir: string;
let harness: AppHarness;
test.beforeAll(async () => {
// 1. 本地 mock Provider(随机端口,仅监听 127.0.0.1
mock = await startMockLLM();
// 2. 隔离的用户数据与工作空间目录
userDataDir = mkdtempSync(join(tmpdir(), 'metona-e2e-user-'));
workspaceDir = join(userDataDir, 'workspace');
mkdirSync(workspaceDir, { recursive: true });
// workspace-config.json 指向隔离工作空间(main.ts 启动时优先读取)
writeFileSync(
join(userDataDir, 'workspace-config.json'),
JSON.stringify({ workspacePath: workspaceDir }),
'utf-8',
);
// 3. 启动 Electron(产物构建由 npm run test:e2e 的 build 步骤保证)
electronApp = await electron.launch({
args: ['.'],
env: {
...process.env,
METONA_USER_DATA_DIR: userDataDir,
METONA_E2E_SEED_CONFIG: '1',
METONA_E2E_LLM_URL: mock.url,
// 隔离冒烟环境无更新源(生产环境 updater 由 feedUrl 空值禁用,双保险)
METONA_E2E_DISABLE_UPDATE: '1',
} as Record<string, string>,
});
page = await electronApp.firstWindow();
await page.waitForLoadState('domcontentloaded');
harness = await launchApp();
});
test.afterAll(async () => {
await electronApp?.close();
await mock?.close();
// 调试期保留 userDatamain.log 排查用);稳定后恢复清理
if (!process.env['METONA_E2E_KEEP_USER_DATA']) {
try {
rmSync(userDataDir, { recursive: true, force: true });
} catch {
/* Windows 文件句柄释放延迟 — 清理失败不影响断言 */
}
}
await harness?.cleanup();
});
test('冒烟:发送消息 → mock LLM 流式回复渲染到会话', async () => {
// 等待聊天输入框可用(配置加载 + 工具就绪后启用)
// InputBase 把 data-chat-input 挂在根 div —— 实际可编辑元素是内部 textarea
//MUI 另渲染一个 readonly 隐藏 textarea 用于测量,需用 .first() 取可交互的)
const input = page.locator('[data-chat-input] textarea').first();
const input = harness.page.locator('[data-chat-input] textarea').first();
await expect(input).toBeVisible({ timeout: 30_000 });
// 发送一条用户消息
@@ -80,18 +39,43 @@ test('冒烟:发送消息 → mock LLM 流式回复渲染到会话', async ()
await input.press('Enter');
// mock LLM 的流式回复出现在聊天区(引擎 → SSE → 渲染层全链路)
await expect(page.getByText(MOCK_REPLY).first()).toBeVisible({ timeout: 30_000 });
await expect(harness.page.getByText(MOCK_REPLY).first()).toBeVisible({ timeout: 30_000 });
// 用户消息持久化回显
await expect(page.getByText('你好,请做一个冒烟回复').first()).toBeVisible();
await expect(harness.page.getByText('你好,请做一个冒烟回复').first()).toBeVisible();
});
test('冒烟:引擎请求体携带设置面板「最大输出上限」配置(2048)', async () => {
// beforeAll 后发送的第一条消息已在 mock.requests 中(顺序执行时上一测试已完成)
const chatRequests = mock.requests.filter((r) => 'messages' in r);
const chatRequests = harness.mock.requests.filter((r) => 'messages' in r);
expect(chatRequests.length).toBeGreaterThanOrEqual(1);
// llm.maxTokens 原样透传(无任何按模型钳制)
expect(chatRequests[0].max_tokens).toBe(2048);
// 请求发往本地 mock(经 adapter 组装),模型名来自种子配置
expect(chatRequests[0].model).toBe('deepseek-v4-flash');
});
test('冒烟:多轮工具调用 → 工具结果回传 → 最终回答渲染', async () => {
const input = harness.page.locator('[data-chat-input] textarea').first();
await expect(input).toBeVisible({ timeout: 30_000 });
await input.click();
await input.fill('请执行 __E2E_TOOL_CALL__ 脚本');
await input.press('Enter');
// 第二轮:mock 收到带 tool 消息的请求后返回最终回答(ReAct 闭环全链路)
await expect(harness.page.getByText(TOOL_REPLY).first()).toBeVisible({ timeout: 30_000 });
// 工具调用卡片(think)存在于聊天流(卡片容器可能滚动出视口,用 attached 断言)
await expect(harness.page.getByText('think').first()).toBeAttached();
// mock 至少收到两条 chat 请求:首轮 tool_calls + 次轮带 tool 结果
const chatRequests = harness.mock.requests.filter(
(r) => 'messages' in r && (r as { messages?: unknown[] }).messages?.length,
);
const toolCallReq = chatRequests.find((r) => JSON.stringify(r).includes('__E2E_TOOL_CALL__'));
const toolResultReq = chatRequests.find((r) =>
(r as { messages?: Array<{ role?: string }> }).messages?.some((m) => m.role === 'tool'),
);
expect(toolCallReq).toBeDefined();
expect(toolResultReq).toBeDefined();
});
+107 -7
View File
@@ -6,6 +6,12 @@
* finish_reason=stop + usage + [DONE]
* - POST /chat/completionsstream=false):非流式 JSON(压缩摘要等内部调用兜底)。
*
* v0.8.2 P3-3: 新增两条可控脚本(内容标记协议,零外部依赖):
* - `__E2E_TOOL_CALL__`:返回 tool_callsthink 工具);后续请求带 tool 消息时
* 返回最终文本 —— 锁定"多轮工具调用 → 观察 → 最终回答"全链路;
* - `__E2E_HANG__`:推送首帧后挂起连接(不结束、不推 finish_reason)——
* 供中断链路测试:客户端 abort 时服务端感知连接关闭。
*
* 端口随机分配(127.0.0.1),测试结束关闭 —— 不触外网、不落真实会话数据。
*/
@@ -15,6 +21,12 @@ import type { AddressInfo } from 'net';
/** 流式回复的固定文本(断言锚点) */
export const MOCK_REPLY = 'Hello from mock LLM. E2E smoke reply.';
/** 多轮工具调用脚本的最终回复(断言锚点) */
export const TOOL_REPLY = 'Tool flow completed OK.';
/** 中断脚本的流式前缀(挂起前推送的第一帧文本) */
export const HANG_PREFIX = 'Hanging stream...';
export interface MockLLMHandle {
url: string;
close: () => Promise<void>;
@@ -22,6 +34,10 @@ export interface MockLLMHandle {
readonly requests: Array<Record<string, unknown>>;
}
function frame(res: import('http').ServerResponse, payload: Record<string, unknown>): void {
res.write(`data: ${JSON.stringify(payload)}\n\n`);
}
export function startMockLLM(): Promise<MockLLMHandle> {
const requests: Array<Record<string, unknown>> = [];
const server: Server = createServer((req, res) => {
@@ -43,6 +59,17 @@ export function startMockLLM(): Promise<MockLLMHandle> {
requests.push(parsed);
const isStream = parsed.stream === true;
const messages = (parsed.messages as Array<Record<string, unknown>>) ?? [];
const hasToolResult = messages.some((m) => m.role === 'tool');
// 脚本标记只看**最后一条用户消息**:恢复请求的历史里会带着早前的
// __E2E_HANG__ 消息,若按全量 JSON 匹配会把恢复请求也误判为挂起脚本。
const lastUserContent = [...messages]
.reverse()
.find((m) => m.role === 'user');
const lastUserText = typeof lastUserContent?.content === 'string' ? lastUserContent.content : '';
const wantsToolCall = lastUserText.includes('__E2E_TOOL_CALL__') && !hasToolResult;
const wantsHang = lastUserText.includes('__E2E_HANG__');
if (!isStream) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
@@ -67,17 +94,90 @@ export function startMockLLM(): Promise<MockLLMHandle> {
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
const frame = (payload: Record<string, unknown>): void => {
res.write(`data: ${JSON.stringify(payload)}\n\n`);
};
// 首 chunk:正文增量
frame({
// ===== 中断脚本:首帧后挂起,客户端 abort 时感知连接关闭 =====
if (wantsHang) {
frame(res, {
id: 'mock-hang',
model: parsed.model ?? 'mock',
choices: [{ index: 0, delta: { role: 'assistant', content: HANG_PREFIX } }],
});
// 挂起即可:不推 finish_reason、不 [DONE]、不 end。
// 注意不要监听 req 'close' —— Node>=16 该事件在请求体接收完成时即触发
//(非连接关闭),主动 destroy 会把挂起变成 'terminated' 断流。
// 客户端 abort 时 socket 关闭由 HTTP 栈自然回收。
// 心跳:每秒发一条 SSE 注释行(':' 前缀,解析器按非 data 帧忽略)——
// 保持连接健康,防止 keep-alive/空闲机制把"挂起"变成提前断流,
// 使中断测试与引擎 ERROR 收尾产生竞态。
const heartbeat = setInterval(() => {
try {
res.write(': keepalive\n\n');
} catch {
clearInterval(heartbeat);
}
}, 1_000);
heartbeat.unref?.();
return;
}
// ===== 多轮工具调用脚本 =====
if (wantsToolCall) {
frame(res, {
id: 'mock-tool',
model: parsed.model ?? 'mock',
choices: [
{
index: 0,
delta: {
role: 'assistant',
tool_calls: [
{
index: 0,
id: 'call_e2e_1',
type: 'function',
function: { name: 'think', arguments: '{"thought":"mock tool call"}' },
},
],
},
},
],
});
frame(res, {
id: 'mock-tool',
model: parsed.model ?? 'mock',
choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }],
usage: { prompt_tokens: 12, completion_tokens: 10, total_tokens: 22 },
});
res.write('data: [DONE]\n\n');
res.end();
return;
}
if (hasToolResult) {
// 工具结果已回传 → 最终回答
frame(res, {
id: 'mock-tool-2',
model: parsed.model ?? 'mock',
choices: [{ index: 0, delta: { role: 'assistant', content: TOOL_REPLY } }],
});
frame(res, {
id: 'mock-tool-2',
model: parsed.model ?? 'mock',
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
usage: { prompt_tokens: 20, completion_tokens: 8, total_tokens: 28 },
});
res.write('data: [DONE]\n\n');
res.end();
return;
}
// ===== 默认冒烟脚本 =====
frame(res, {
id: 'mock-1',
model: parsed.model ?? 'mock',
choices: [{ index: 0, delta: { role: 'assistant', content: MOCK_REPLY } }],
});
// 末帧:finish_reason + usage
frame({
frame(res, {
id: 'mock-1',
model: parsed.model ?? 'mock',
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],