AionUi 中 Aion CLI(aionrs)E2E 测试需求全解析:从维度设计、用例矩阵到数据库验证
【免费下载链接】AionUiOpen-source 24/7 Cowork app for OpenClaw, Hermes, Claude Code, Codex, OpenCode and 20+ more CLI Agent | Customize your assistants | Team them up|Star if you like it!项目地址: https://gitcode.com/GitHub_Trending/ai/AionUi
AionUi 桌面端内置了对 Aion CLI(aionrs)agent 的支持,用户可以在 guid 首页选择 aionrs、配置上下文与权限模式后发起对话,并通过进程端以 stdin/stdout JSON Lines 协议驱动 aionrs binary 完成流式回复。本文以仓库中 chat-aionrs 测试需求文档 为主体,结合源码实现与 Gate 2 用例文档,完整拆解 aionrs 的端到端测试需求:五维测试矩阵(文件夹关联、文件上传、模型、权限、对话中切换)、binary 前置条件与超时策略、临时目录清理契约、数据库验证字段结构,以及>const { providers: allProviders, getAvailableModels, formatModelLabel } = useModelProviderList(); // AionCore does not support Google Auth — filter it out const providers = useMemo( () => allProviders.filter((p) => !p.platform?.toLowerCase().includes('gemini-with-google-auth')), [allProviders] );
provider 列表的底层来源是 useModelProviderList.ts,它通过useSWR拉取ipcBridge.mode.listProviders.invoke()返回的用户配置 provider 列表(并额外过滤p.enabled !== false的禁用项),即用户配置文件存储的 provider 列表,而非动态探测。
档位定义(runtime 动态决定,不可 hardcode):
- 档位 1 - 默认模型:E2E setup 查询
ipcBridge.mode.getModelConfig.invoke(),过滤gemini-with-google-auth,取providers[0].model[0](第一个可用 provider 的第一个 model) - 档位 2 - 切换模型:同一 provider 下的不同 model(
providers[0].model[1]),或跨 provider(providers[1].model[0],若存在第二个 provider)
E2E 前置条件:
- 至少 1 个非 Google Auth provider
- 该 provider 至少有 2 个可用 model(用于切换测试)
- 若只有 1 个 model,测试降级为"只验证当前 model,不测切换"
验证策略:
- 通过
invokeBridge(page, 'conversation.get', { id })查询 DB - 校验
conversations.model字段(模型 ID) - 校验
conversations.extra.model(JSON)包含{ id, useModel, name, ... }
已知约束:aionrs 不支持 Google Auth;模型切换行为 E2E 只验证 DB 字段更新,不验证 binary 重启(见 §8 议题 1 决策)。
2.5 权限模式(3 档)
档位枚举由 aionrs runtime capabilities 上报:
aionrs: [ { value: 'default', label: 'Default' }, { value: 'auto_edit', label: 'Auto-Accept Edits' }, { value: 'yolo', label: 'YOLO' }, ];| mode | label | 行为 |
|---|---|---|
default | Default | 每次工具调用需确认 |
auto_edit | Auto-Accept Edits | 自动批准 edit / info 类工具,exec / mcp 仍需确认 |
yolo | YOLO | 全自动批准所有工具 |
权限切换接口:
- guid 页:
GuidActionRow.tsx—AgentModeSelector组件 - 对话页:
AionrsSendBox.tsx— 同样使用AgentModeSelector - 进程端:
setMode()更新 DB + 发送set_mode到 binary
AgentModeSelector.tsx 中可以看到 aionrs 权限选项的 testid 落地(data-testid={'aionrs-mode-option-' + mode.value}),说明 Gate 2/3 阶段的 testid 补充工作已部分反映在源码中。
验证策略:查询 DBSELECT json_extract(extra, '$.sessionMode') FROM conversations WHERE id = ?,期望值'default' | 'auto_edit' | 'yolo'。
测试覆盖:guid 页选 3 种模式各 1 次(3 个用例)+ 对话中切换 3 种模式。
2.6 对话中切换(必测)
- 切换模型:点击对话页
AionrsModelSelector按钮 → 选择不同模型;预期下次发送消息时生效(是否需重启 binary 待 E2E 探测);验证 DBconversations.extra.model.useModel - 切换权限:点击对话页
AgentModeSelector按钮 → 选择不同权限;预期立即生效(setMode()发送set_mode到 binary);验证 DBconversations.extra.sessionMode - 边界场景:工具确认弹窗中途切换权限 → 当前行为待探测(是否取消 pending 确认?)
2.7 用例矩阵(正交覆盖)
全排列为 2(文件夹)× 2(上传)× 2(模型)× 3(权限)=24 组合,通过收敛策略缩减为 11-17 个推荐用例:
- P0 核心用例(5 个):无附件 + 默认模型 + default 权限(基础路径)
- P1 常用用例(7 个):单文件、单文件夹、多文件、多文件夹、切换模型、切换权限
- P2 边界用例(3-5 个):超大文件、并发对话、协议错误、进程崩溃
推荐用例清单(正交设计):
| # | 文件夹关联 | 文件上传 | 模型 | 权限 | 对话中切换 | 优先级 |
|---|---|---|---|---|---|---|
| 1 | 无 | 无 | 默认 | default | - | P0 |
| 2 | 关联 | 无 | 默认 | default | - | P1 |
| 3 | 无 | 上传 | 默认 | default | - | P1 |
| 4 | 关联 | 上传 | 默认 | default | - | P1 |
| 5 | 无 | 无 | 默认 | auto_edit | - | P1 |
| 6 | 无 | 无 | 默认 | yolo | - | P1 |
| 7 | 无 | 无 | 默认 | default | 切换模型 | P1 |
| 8 | 无 | 无 | 默认 | default | 切换权限 | P1 |
| 9 | 无 | 上传(超大) | 默认 | default | - | P2 |
| 10 | 无 | 无 | 默认 | default | 并发对话 | P2 |
| 11 | 无 | 无 | 默认 | default | binary 崩溃 | P2 |
3. Binary 前置条件
3.1 Binary 路径解析
路径解析策略二选一,推荐选项 2:
选项 1 - Hardcode 路径(不推荐,脆弱):
const AIONRS_BINARY_PATH = '/Users/zhoukai/.local/bin/aionrs';选项 2 - 从 PATH 查找(推荐,更健壮):
// 通过 binaryResolver 查找 const binary = await ipcBridge.fs.findAionrsBinary.invoke(); if (!binary) { test.skip('aionrs binary not found in PATH or ~/.local/bin/aionrs, skipping E2E tests'); }解析顺序(binaryResolver):环境变量AION_CLI_PATH→~/.aionui/bin/aion-<platform>-<arch>→ 系统 PATH 中的aion命令。
E2E 实现规范:
// tests/e2e/setup/aionrs.setup.ts export async function checkAionrsBinary(page: Page): Promise<boolean> { try { const binary = await invokeBridge(page, 'fs.findAionrsBinary'); if (!binary) { console.error('[E2E Setup] aionrs binary not found in PATH or ~/.local/bin/aionrs'); return false; } console.log(`[E2E Setup] aionrs binary found: ${binary}`); return true; } catch (error) { console.error('[E2E Setup] Failed to check aionrs binary:', error); return false; } } // tests/e2e/specs/chat-aionrs/*.spec.ts test.beforeAll(async ({ page }) => { const hasBinary = await checkAionrsBinary(page); if (!hasBinary) { test.skip('aionrs binary not found, skipping E2E tests'); } });关键要求:若 binary 不存在,必须test.skip()并打印明确错误信息(不要悄悄跳过)。
3.2 Binary 启动与超时
启动流程:
- 创建 aionrs agent 实例
- 调用
spawn(binaryPath, args, { env, stdio: ['pipe', 'pipe', 'pipe'] }) - 等待
ready事件(JSON Lines:{"type":"ready","session_id":"...","capabilities":{...}}) - 超时时间:30s
E2E timeout 设置:
test( 'should start aionrs conversation', async ({ page }) => { // Playwright test timeout: 60s(留足 binary 启动时间) }, { timeout: 60000 } );失败场景:
- 启动超时:抛出
Error('aionrs ready timeout (30s)') - 进程崩溃:
childProcess.on('exit')触发,前端收到error事件
4. 临时目录与清理契约
4.1 临时目录规范
/tmp/e2e-chat-aionrs-<scenario>-<timestamp>/ ├── test-file.txt # 文件上传测试文件 ├── test-folder/ # 文件夹关联测试目录 │ └── sample.md └── .aionrs/ # aionrs session 文件(binary 自动创建)命名规范:<scenario>为用例场景描述(如no-attach、single-file、single-folder、multi-attach);<timestamp>为Date.now()或YYYYMMDD-HHmmss。创建时机:beforeEach()或用例开始前。
4.2 清理契约(必须按序执行,避免外键冲突)
afterEach(async ({ page }) => { const conversationId = /* 当前用例的对话 ID */; const tmpDir = /* 当前用例的临时目录 */; try { // 1. 停止 aionrs binary 进程 await invokeBridge(page, 'conversation.stop', { conversation_id: conversationId }); // 2. 清理 DB(级联删除 messages) await invokeBridge(page, 'db.exec', { sql: "DELETE FROM conversations WHERE name LIKE 'E2E-aionrs-%'" }); // 3. 清理 FS(临时目录 + aionrs session 文件) await invokeBridge(page, 'fs.rm', { path: tmpDir, recursive: true }); // 4. 清理 UI state(ESC×5 关闭所有弹窗/模态框) for (let i = 0; i < 5; i++) { await page.keyboard.press('Escape'); await page.waitForTimeout(100); } // 5. 清理 sessionStorage await page.evaluate(() => { sessionStorage.clear(); }); // 6. 验证清理完成(可选,但推荐) const remaining = await invokeBridge(page, 'db.query', { sql: "SELECT COUNT(*) as count FROM conversations WHERE name LIKE 'E2E-aionrs-%'" }); if (remaining[0].count > 0) { throw new Error(`E2E cleanup failed: ${remaining[0].count} conversations still exist`); } } catch (error) { // 清理失败必须 throw(team-lead 硬性要求) console.error('[E2E Cleanup] Failed:', error); throw error; } });清理范围总表:
| 资源类型 | 清理规则 | 验证方式 |
|---|---|---|
| DB conversations | DELETE WHERE name LIKE 'E2E-aionrs-%' | SELECT COUNT(*)期望 0 |
| DB messages | 级联删除(ON DELETE CASCADE) | 自动清理 |
| FS 临时目录 | rm -rf /tmp/e2e-chat-aionrs-* | fs.existsSync()期望 false |
| FS aionrs session | 包含在临时目录内 | 同上 |
| UI state | ESC×5 + 导航到安全页面(如/guid) | 截图验证 |
| sessionStorage | clear() | sessionStorage.length === 0 |
对话命名规范(清理 SQL 依赖此前缀):
const conversationName = `E2E-aionrs-${scenario}-${Date.now()}`; // 示例: 'E2E-aionrs-no-attach-1745327890123'关键要求:前缀必须是E2E-aionrs-;包含场景描述(便于日志追溯);包含时间戳(避免重名)。
5. 数据库验证字段
5.1 conversations 表验证
| 字段 | 类型 | 验证规则 |
|---|---|---|
id | TEXT PK | 非空,UUID 格式 |
name | TEXT | 匹配'E2E-aionrs-*'模式 |
type | TEXT | 固定'aionrs' |
model | TEXT | 模型 ID(如'claude-opus-4-7') |
status | TEXT | 'pending' \| 'running' \| 'finished' |
extra | TEXT (JSON) | 见 extra 字段结构 |
created_at | INTEGER | 时间戳(ms) |
updated_at | INTEGER | ≥ created_at |
extra 字段结构(JSON)
{ "workspace": "/tmp/e2e-chat-aionrs-...", "sessionMode": "default" | "auto_edit" | "yolo", "lastTokenUsage": { "totalTokens": 1234 }, "model": { "id": "anthropic", "useModel": "claude-opus-4-7", "name": "Anthropic", "baseUrl": "https://api.anthropic.com/v1", "platform": "claude", "...": "..." } }对应关系:sessionMode由saveSessionMode()持久化;lastTokenUsage由saveContextUsage()持久化;model由 guid 页模型选择 hook 持久化。
5.2 messages 表验证
| 字段 | 类型 | 验证规则 |
|---|---|---|
id | TEXT PK | 非空,UUID 格式 |
conversation_id | TEXT FK | 关联 conversations.id |
msg_id | TEXT | binary 流式 msg_id(AI 回复)或用户消息 ID |
type | TEXT | 'text' \| 'tool_group' \| 'thinking' \| ... |
content | TEXT (JSON) | 见 content 字段结构 |
position | TEXT | 'left' \| 'right' \| 'center' \| 'pop' |
status | TEXT | 'finish' \| 'pending' \| 'error' \| 'work' |
created_at | INTEGER | 时间戳(ms) |
content 字段结构(JSON)
用户消息(position='right'):
{ "content": "用户输入的消息文本", "attachedFiles": ["/tmp/.../test.txt"], "attachedDirs": ["/tmp/.../test-folder"] }AI 文本回复(type='text', position='left'):
{ "content": "AI 回复的文本内容(增量拼接)" }思考消息(type='thinking'):
{ "content": "<think> 标签或 thought 事件内容", "duration": 1234, "status": "thinking" | "done" }工具调用(type='tool_group'):
[ { "callId": "tool_call_uuid", "name": "edit_file", "description": "Edit /tmp/.../test.txt", "status": "Confirming" | "Executing" | "Success" | "Error" | "Canceled", "resultDisplay": "...", "renderOutputAsMarkdown": false } ]5.3 E2E 断言示例
test('should verify DB records after conversation', async ({ page }) => { const conversationId = /* ... */; // 1. 验证 conversation 存在且类型正确 const conv = await invokeBridge(page, 'conversation.get', { id: conversationId }); expect(conv).toBeDefined(); expect(conv.type).toBe('aionrs'); expect(conv.status).toBe('finished'); expect(conv.extra.sessionMode).toBe('default'); // 2. 验证至少有 2 条消息(用户 + AI) const messages = await invokeBridge(page, 'db.query', { sql: 'SELECT * FROM messages WHERE conversation_id = ? ORDER BY created_at ASC', params: [conversationId] }); expect(messages.length).toBeGreaterThanOrEqual(2); // 3. 验证用户消息 const userMsg = messages[0]; expect(userMsg.position).toBe('right'); expect(userMsg.type).toBe('text'); expect(JSON.parse(userMsg.content).content).toContain('Hello'); // 4. 验证 AI 回复 const aiMsg = messages.find(m => m.position === 'left' && m.type === 'text'); expect(aiMsg).toBeDefined(); expect(aiMsg.status).toBe('finish'); expect(JSON.parse(aiMsg.content).content).toBeTruthy(); });6. 可测试性评估与>// AionrsSendBox.tsx <div>// 发送按钮 <Button>test('should complete aionrs conversation with no attachments', async ({ page }) => { // 1. 导航到 guid 页 await page.goto('/#/guid'); // 2. 选择 aionrs agent await page.click('[data-agent-backend="aionrs"][data-agent-selected="false"]'); // 3. 输入消息(通过 Playwright locator) const textarea = page.locator('textarea[placeholder*="aionrs"]'); await textarea.fill('Hello, aionrs!'); // 4. 点击发送按钮 await page.click('.send-button-custom'); // 或 [data-testid="aionrs-send-btn"] // 5. 等待导航到对话页 await page.waitForURL(/\/conversation\/aionrs\/.+/, { timeout: 10000 }); // 6. 提取 conversationId const url = page.url(); const conversationId = url.match(/\/conversation\/aionrs\/(.+)/)?.[1]; expect(conversationId).toBeTruthy(); // 7. 等待 AI 回复(轮询 DB) await waitForAIResponse(page, conversationId, { timeout: 60000 }); // 8. 验证 DB 记录 const messages = await invokeBridge(page, 'db.query', { sql: 'SELECT * FROM messages WHERE conversation_id = ? ORDER BY created_at', params: [conversationId], }); expect(messages.length).toBeGreaterThanOrEqual(2); expect(messages[0].position).toBe('right'); // 用户消息 expect(messages[1].position).toBe('left'); // AI 回复 });7. 边界与异常场景
7.1 Binary 层异常
| 场景 | 预期行为 |
|---|---|
| binary 不存在 | test.skip()+ 明确错误信息 |
| 启动超时(30s) | 抛出Error('aionrs ready timeout') |
| 进程崩溃 | 前端收到error事件,对话标记为 error |
| resume 失败 | 自动降级为新 session |
7.2 并发对话
同时打开 2 个 aionrs 对话,轮流发送消息。预期每个对话独立维护 binary 进程 + session(进程管理实例独立)。验证点:DB 中有 2 条conversations记录(不同id);每个对话有各自的sessionId(从 binaryready事件获取)。
7.3 超大文件上传
上传 >100MB 文件(或 >1000 个文件)。预期前端文件处理层(FileService.processDroppedFiles())限制大小/数量并显示错误提示。验证点:E2E 创建 100MB 测试文件尝试上传,验证是否显示错误提示。
7.4 协议错误
Binary 返回非法 JSON Lines(如{"type":"unknown"})。预期前端解析失败、记录错误日志、不崩溃。对应的处理模式是进程端用try-catch包裹JSON.parse()。
8. 待决策议题与决策结果
需求文档记录了四个待决策议题及其决策状态,其中三个已有明确结论:
| 议题 | 内容 | 决策 |
|---|---|---|
| 议题 0(新增) | E2E 环境 provider 配置前置条件 | 至少 1 个非 Google Auth provider 且至少有 2 个可用 model;不满足则降级或 skip |
| 议题 1 | 模型切换是否需重启 binary?(P0) | E2E 探测式测试记录当前行为,只验证 DB 字段更新 |
| 议题 2 | 权限 "always allow" 是否需持久化?(P2) | B:保持内存存储(AionrsApprovalStore),持久化属产品需求 |
| 议题 4 | CI 环境 binary 来源?(P0) | C:短期 skip,长期 DevOps 配置 |
议题 0 动态 model 选择实现:
// tests/e2e/setup/aionrs.setup.ts export async function getAionrsTestModels(page: Page): Promise<{ defaultModel: { providerId: string; modelId: string } | null; switchModel: { providerId: string; modelId: string } | null; }> { const providers = await invokeBridge(page, 'mode.getModelConfig'); // 过滤 gemini-with-google-auth const availableProviders = providers.filter( (p) => !p.platform?.toLowerCase().includes('gemini-with-google-auth') && p.enabled !== false ); if (availableProviders.length === 0) return { defaultModel: null, switchModel: null }; const firstProvider = availableProviders[0]; const models = firstProvider.model || []; return { defaultModel: models.length > 0 ? { providerId: firstProvider.id, modelId: models[0] } : null, switchModel: models.length > 1 ? { providerId: firstProvider.id, modelId: models[1] } : null, }; }降级策略:若只有 1 个 model,测试降级为"只验证当前 model,跳过切换场景";若无可用 provider,test.skip('No available providers for aionrs, skipping E2E tests')。
议题 1 模型切换探测测试:
test('模型切换探测', async ({ page }) => { // 1. 发送消息 A(模型 M1) // 2. 切换模型到 M2 // 3. 发送消息 B // 4. 查 DB:messages[B].extra.model === M2 ? '运行时切换生效' : '需重启' });9. 交付文档清单与后续路线
Gate 流程产出的文档清单:
| 文档 | 路径 | 状态 |
|---|---|---|
| Gate 1 需求文档 | requirements.zh.md | ✅ 完成 |
| Gate 1 讨论记录 | discussion-log.zh.md | ✅ 完成(双 reviewer 审核) |
| Gate 2 测试用例 | test-cases.zh.md | ✅ 已有初稿(TC-A-01 ~ TC-A-15,P0/P1/P2 分级) |
| Gate 3 实现映射 | implementation-mapping.zh.md | 待完善 |
下一步路线(Gate 1 → Gate 3):
- ✅ Gate 1 完成:analyst 起草需求 + designer/engineer 审核 + team-lead 决策
- ⏳ Gate 2:designer 起草 test-cases(15 个用例,P0/P1/P2 分级)
- ⏳ 前置工作(阻塞 Gate 3):engineer 补充对话页 contenteditable="false">【免费下载链接】AionUiOpen-source 24/7 Cowork app for OpenClaw, Hermes, Claude Code, Codex, OpenCode and 20+ more CLI Agent | Customize your assistants | Team them up|Star if you like it!
项目地址: https://gitcode.com/GitHub_Trending/ai/AionUi
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考