# ZCLAW 前端完整验证方案 ## 背景 当前 E2E 测试(`functional-scenarios.spec.ts`)主要验证 UI 表面: - 验证元素是否存在 - 验证按钮能否点击 - 验证对话框能否打开 **问题**:这些测试无法发现真正的功能问题,例如: - 点击按钮后 API 是否被正确调用 - Store 状态是否正确更新 - 数据是否正确渲染 - 边界情况是否处理 ## 目标 设计一套**深度模拟用户操作**的验证方案,确保: 1. **完整操作流程** - 从用户视角完成端到端操作 2. **数据流验证** - 检查 UI → Store → API → 后端的完整链路 3. **状态变化验证** - 验证操作前后状态正确转换 4. **结果展示验证** - 验证返回数据正确渲染到 UI 5. **边界情况验证** - 验证错误处理、空状态、加载状态 --- ## 验证架构 ### 测试分层 ``` ┌─────────────────────────────────────────────────────────┐ │ E2E 用户流程测试 │ │ (Playwright - 完整用户操作 + 数据流验证) │ ├─────────────────────────────────────────────────────────┤ │ Store 状态测试 │ │ (Vitest - Store 逻辑 + 状态转换验证) │ ├─────────────────────────────────────────────────────────┤ │ API 集成测试 │ │ (MSW Mock - 请求/响应验证) │ └─────────────────────────────────────────────────────────┘ ``` ### 新增测试文件 ``` desktop/tests/ ├── e2e/ │ ├── specs/ │ │ ├── functional-scenarios.spec.ts # 现有 - 保留 │ │ ├── data-flow.spec.ts # 新增 - 数据流深度验证 │ │ ├── store-state.spec.ts # 新增 - Store 状态验证 │ │ └── edge-cases.spec.ts # 新增 - 边界情况测试 │ ├── fixtures/ │ │ ├── mock-gateway.ts # 新增 - Gateway Mock │ │ ├── test-data.ts # 新增 - 测试数据工厂 │ │ └── store-inspectors.ts # 新增 - Store 检查工具 │ └── utils/ │ ├── network-helpers.ts # 新增 - 网络拦截工具 │ ├── store-assertions.ts # 新增 - Store 断言 │ └── user-actions.ts # 新增 - 用户操作模拟 └── store/ └── *.test.ts # 现有 - 扩展 Store 单元测试 ``` --- ## 模块验证用例 ### 1. 聊天模块 (ChatArea.tsx + chatStore.ts) #### 数据流验证 ``` 用户输入 → sendMessage() → gateway-client.chatStream() → WebSocket 事件 → initStreamListener() → Store 更新 → UI 渲染 ``` #### 深度验证用例 | 用例 ID | 验证场景 | 验证步骤 | 断言点 | |---------|----------|----------|--------| | CHAT-DF-01 | 发送消息并接收流式响应 | 1. 输入消息 2. 点击发送 3. 拦截 WebSocket 4. 验证请求格式 5. 模拟流式响应 6. 验证 Store 状态 7. 验证 UI 渲染 | ① 请求包含正确 sessionKey ② isStreaming=true ③ 消息增量追加 ④ streaming=false ⑤ messages 数组更新 | | CHAT-DF-02 | 模型切换 | 1. 点击模型选择器 2. 选择新模型 3. 发送消息 | ① Store.currentModel 更新 ② 请求使用新模型 ③ UI 显示新模型名 | | CHAT-DF-03 | Agent 切换 | 1. 当前有消息 2. 切换 Agent 3. 验证状态保存 4. 切换回来 | ① 会话保存到 conversations ② 消息清空 ③ sessionKey 更新 ④ 切回后恢复原消息 | | CHAT-DF-04 | 新建对话 | 1. 发送消息 2. 点击新建 3. 验证状态 | ① 消息清空 ② currentConversationId=null ③ sessionKey=null | | CHAT-DF-05 | 网络错误处理 | 1. 模拟网络断开 2. 发送消息 | ① 错误消息显示 ② isStreaming=false ③ error 字段设置 ④ 可重试 | | CHAT-DF-06 | 流式中断 | 1. 开始流式响应 2. 中途断开连接 | ① 部分消息保留 ② 错误指示器显示 ③ 可恢复 | #### Store 断言示例 ```typescript // 验证 chatStore 状态 const chatState = await page.evaluate(() => { const stored = localStorage.getItem('zclaw-chat-storage'); return stored ? JSON.parse(stored).state : null; }); expect(chatState).toMatchObject({ isStreaming: false, messages: expect.arrayContaining([ expect.objectContaining({ role: 'user', content: 'test' }), expect.objectContaining({ role: 'assistant', streaming: false }), ]), }); ``` --- ### 2. 分身管理 (CloneManager.tsx + agentStore.ts) #### 数据流验证 ``` UI → createClone() → POST /api/clones → Response → clones 更新 → UI 重渲染 ``` #### 深度验证用例 | 用例 ID | 验证场景 | 验证步骤 | 断言点 | |---------|----------|----------|--------| | CLONE-DF-01 | 创建分身 | 1. 点击创建 2. 填写表单 3. 提交 4. 拦截 API 请求 | ① POST 请求格式正确 ② Store.clones 增加 ③ UI 显示新分身 | | CLONE-DF-02 | 切换分身 | 1. 当前在 Agent A 2. 切换到 Agent B | ① currentAgent 更新 ② 聊天消息切换 ③ sessionKey 更新 | | CLONE-DF-03 | 删除分身 | 1. 选择分身 2. 点击删除 3. 确认 | ① DELETE API 调用 ② Store 移除 ③ 回退到默认 | | CLONE-DF-04 | 修改配置 | 1. 打开设置 2. 修改字段 3. 保存 | ① PATCH 请求含 delta ② Store 更新 ③ UI 显示新值 | --- ### 3. Hands 系统 (HandsPanel.tsx + handStore.ts) #### 数据流验证 ``` 组件挂载 → loadHands() → GET /api/hands → 数据映射 → hands 数组 → UI 渲染 触发执行 → triggerHand() → POST /api/hands/:name/trigger → 状态变更 → UI 更新 ``` #### 深度验证用例 | 用例 ID | 验证场景 | 验证步骤 | 断言点 | |---------|----------|----------|--------| | HAND-DF-01 | 加载 Hands 列表 | 1. 导航到 Hands 2. 拦截 API 3. 验证映射 | ① GET /api/hands 调用 ② 状态徽章正确 ③ requirements_met 显示 | | HAND-DF-02 | 触发 Hand 执行 | 1. 点击执行 2. 如有参数则填写 3. 提交 | ① POST 触发请求 ② status → running ③ currentRunId 设置 | | HAND-DF-03 | 参数表单验证 | 1. 展开 Hand 2. 填写各类型参数 3. 验证失败 4. 修正后提交 | ① 类型验证生效 ② 错误提示显示 ③ 正确值传递 | | HAND-DF-04 | 审批流程 | 1. 触发需审批 Hand 2. 状态 needs_approval 3. 批准/拒绝 | ① 审批对话框显示 ② approveHand API 调用 ③ 状态更新 | | HAND-DF-05 | 执行历史 | 1. 查看 Hand 详情 2. 历史记录标签 | ① GET /api/hands/:name/runs ② 记录正确显示 | #### 参数类型验证矩阵 | 参数类型 | 验证项 | |----------|--------| | text | 空值、最大长度、正则 pattern | | number | 边界值、min/max、非数字 | | boolean | 切换状态、默认值 | | select | 选项列表、默认选中 | | array | 添加/删除项、空数组 | | object | JSON 解析、格式验证 | | file | 文件选择、大小限制 | --- ### 4. 自动化面板 (AutomationPanel.tsx) #### 深度验证用例 | 用例 ID | 验证场景 | 验证步骤 | 断言点 | |---------|----------|----------|--------| | AUTO-DF-01 | 分类过滤 | 1. 点击分类 2. 验证列表 | ① 请求带 category 参数 ② 列表过滤正确 ③ 空状态处理 | | AUTO-DF-02 | 搜索功能 | 1. 输入关键词 2. 实时过滤 | ① 匹配名称/描述 ② 高亮显示 ③ 无结果提示 | | AUTO-DF-03 | 批量执行 | 1. 多选 Hands 2. 批量触发 | ① 选中状态管理 ② 依次触发 API ③ 进度显示 | | AUTO-DF-04 | 视图切换 | 1. 网格视图 2. 列表视图 | ① 布局变化 ② 数据一致 ③ 响应式适配 | --- ### 5. 工作流编辑 (WorkflowEditor.tsx + workflowStore.ts) #### 深度验证用例 | 用例 ID | 验证场景 | 验证步骤 | 断言点 | |---------|----------|----------|--------| | WF-DF-01 | 创建工作流 | 1. 打开编辑器 2. 添加步骤 3. 配置参数 4. 保存 | ① POST 请求格式 ② steps 数组正确 ③ Store 更新 | | WF-DF-02 | 步骤操作 | 1. 添加步骤 2. 上移/下移 3. 删除 | ① 顺序更新 ② Hand 选择器 ③ JSON 参数解析 | | WF-DF-03 | 执行工作流 | 1. 选择工作流 2. 触发执行 | ① POST execute ② 状态 running ③ 步骤进度显示 | | WF-DF-04 | 验证规则 | 1. 空名称 2. 无步骤 3. 保存 | ① 验证错误显示 ② 禁用保存按钮 | --- ### 6. 技能市场 (SkillMarket.tsx) #### 深度验证用例 | 用例 ID | 验证场景 | 验证步骤 | 断言点 | |---------|----------|----------|--------| | SKILL-DF-01 | 浏览技能 | 1. 加载目录 2. 验证数据 | ① GET /api/skills ② 分类显示 ③ 安装状态 | | SKILL-DF-02 | 搜索技能 | 1. 输入关键词 2. 验证匹配 | ① 名称/能力/触发词匹配 ② 结果排序 | | SKILL-DF-03 | 安装技能 | 1. 点击安装 2. 等待完成 | ① 安装 API 调用 ② 状态变更 ③ UI 更新 | | SKILL-DF-04 | 技能详情 | 1. 展开/点击 2. 查看详情 | ① 触发词列表 ② 工具依赖 ③ 使用示例 | --- ### 7. 团队协作 (TeamList.tsx + teamStore.ts) #### 深度验证用例 | 用例 ID | 验证场景 | 验证步骤 | 断言点 | |---------|----------|----------|--------| | TEAM-DF-01 | 创建团队 | 1. 填写表单 2. 选择成员 3. 选择模式 4. 创建 | ① POST 格式正确 ② members 数组 ③ pattern 字段 | | TEAM-DF-02 | 成员管理 | 1. 添加成员 2. 分配角色 3. 移除 | ① Store 更新 ② 角色字段 ③ UI 反映 | | TEAM-DF-03 | 任务分配 | 1. 创建任务 2. 分配成员 3. 更新状态 | ① tasks 数组 ② assigneeId ③ status 变化 | | TEAM-DF-04 | Dev↔QA 循环 | 1. 启动循环 2. 提交审查 3. 反馈 4. 迭代 | ① activeLoops 更新 ② iteration 递增 ③ state 转换 | --- ### 8. 设置页面 (Settings/*) #### 深度验证用例 | 用例 ID | 验证场景 | 验证步骤 | 断言点 | |---------|----------|----------|--------| | SET-DF-01 | 通用设置 | 1. 修改字段 2. 保存 | ① PATCH 请求 ② Store 更新 ③ 成功提示 | | SET-DF-02 | 模型配置 | 1. 添加 API Key 2. 测试连接 | ① 密钥掩码显示 ② 连接测试 API ③ 模型列表加载 | | SET-DF-03 | MCP 服务 | 1. 查看列表 2. 启用/禁用 | ① GET /api/plugins ② PATCH 状态 ③ UI 更新 | | SET-DF-04 | 审计日志 | 1. 打开日志 2. 筛选 3. 分页 | ① 日志加载 ② 过滤参数 ③ 分页正确 | --- ### 9. 审批系统 (ApprovalQueue.tsx) #### 深度验证用例 | 用例 ID | 验证场景 | 验证步骤 | 断言点 | |---------|----------|----------|--------| | APPROVE-DF-01 | 查看队列 | 1. 加载审批列表 | ① GET /api/approvals?status=pending ② 详情显示 | | APPROVE-DF-02 | 批准操作 | 1. 选择审批 2. 批准 | ① POST respond (approved=true) ② 队列移除 ③ 状态更新 | | APPROVE-DF-03 | 拒绝操作 | 1. 选择审批 2. 拒绝 3. 填写原因 | ① 原因字段 ② POST respond (approved=false) ③ 队列更新 | --- ## 边界情况验证清单 ### 网络边界 | 场景 | 预期行为 | 验证方法 | |------|----------|----------| | Gateway 断开 | 连接指示器红色,输入禁用,错误消息 | 模拟断开,检查 UI 状态 | | WebSocket 重连 | 自动重连尝试,状态保留,指示器显示 | 断开后恢复,验证状态 | | 请求超时 | 超时错误显示,重试选项可用 | 设置短超时,检查处理 | | 无效响应 | 解析错误处理,优雅降级 | 返回畸形 JSON | | 限流 (429) | 用户通知,自动退避重试 | 模拟 429 响应 | ### 数据边界 | 场景 | 预期行为 | 验证方法 | |------|----------|----------| | 空消息列表 | 欢迎状态,开始提示 | 清空数据,检查 UI | | 超长消息 | 容器内滚动,布局不破 | 输入 10000+ 字符 | | 空 Hands 列表 | "无可用 Hands" + 连接提示 | Mock 空响应 | | 无效 JSON 参数 | 解析错误显示,原值保留 | 输入畸形 JSON | | 大文件上传 | 进度指示,大小限制检查 | 选择超大文件 | ### 状态边界 | 场景 | 预期行为 | 验证方法 | |------|----------|----------| | 快速连续点击 | 防抖,单次操作,正确最终状态 | 连续点击 10 次 | | 流式响应中切换 | 取消/完成当前,干净切换 | 发消息后立即切换 | | 浏览器刷新 | 从 localStorage 恢复,会话保留 | 刷新后检查状态 | | 标签页切换 | 操作继续,返回时状态同步 | 切换标签再返回 | --- ## 实现方案 ### 1. 网络拦截工具 ```typescript // tests/e2e/utils/network-helpers.ts export const networkHelpers = { // 拦截并记录所有 API 请求 async interceptAllAPI(page: Page) { const requests: Array<{ url: string; method: string; body?: any }> = []; await page.route('**/api/**', async (route) => { const request = route.request(); requests.push({ url: request.url(), method: request.method(), body: request.postDataJSON(), }); await route.continue(); }); return requests; }, // Mock API 响应 async mockAPI(page: Page, path: string, response: any, status = 200) { await page.route(`**/api/${path}**`, async (route) => { await route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(response), }); }); }, // 模拟网络错误 async simulateNetworkError(page: Page, path: string) { await page.route(`**/api/${path}**`, async (route) => { await route.abort('failed'); }); }, // 模拟延迟响应 async simulateDelay(page: Page, path: string, delayMs: number) { await page.route(`**/api/${path}**`, async (route) => { await new Promise(r => setTimeout(r, delayMs)); await route.continue(); }); }, }; ``` ### 2. Store 检查工具 ```typescript // tests/e2e/fixtures/store-inspectors.ts export const storeInspectors = { // 获取持久化的 Store 状态 async getPersistedState(page: Page, storeName: string) { return page.evaluate((name) => { const stored = localStorage.getItem(`zclaw-${name}-storage`); return stored ? JSON.parse(stored).state : null; }, storeName); }, // 等待 Store 状态变化 async waitForStateChange(page: Page, storeName: string, path: string, expectedValue: any) { await page.waitForFunction( ({ store, path, expected }) => { const stored = localStorage.getItem(`zclaw-${store}-storage`); if (!stored) return false; const state = JSON.parse(stored).state; const value = path.split('.').reduce((obj, key) => obj?.[key], state); return JSON.stringify(value) === JSON.stringify(expected); }, { store: storeName, path, expected: expectedValue }, { timeout: 5000 } ); }, // 验证 Store 状态 async assertStoreState(page: Page, storeName: string, expected: Record) { const state = await this.getPersistedState(page, storeName); for (const [key, value] of Object.entries(expected)) { expect(state?.[key]).toEqual(value); } }, }; ``` ### 3. 用户操作模拟 ```typescript // tests/e2e/utils/user-actions.ts export const userActions = { // 完整聊天流程 async sendChatMessage(page: Page, message: string) { const chatInput = page.locator('textarea').first(); await chatInput.waitFor({ state: 'visible' }); await chatInput.fill(message); // 拦截请求 const [request] = await Promise.all([ page.waitForRequest('**/api/chat**'), page.getByRole('button', { name: '发送消息' }).click(), ]); return request; }, // 创建分身完整流程 async createClone(page: Page, data: { name: string; role?: string }) { await page.getByRole('button', { name: /创建|new/i }).click(); await page.waitForSelector('[role="dialog"]'); const dialog = page.locator('[role="dialog"]'); await dialog.locator('input').first().fill(data.name); const [response] = await Promise.all([ page.waitForResponse('**/api/clones'), dialog.getByRole('button', { name: /确认|创建|save/i }).click(), ]); return response; }, // 触发 Hand 完整流程 async triggerHand(page: Page, handName: string, params?: Record) { // 导航到 Hands await page.getByRole('tab', { name: 'Hands' }).click(); // 找到并点击 Hand const handCard = page.locator(`[data-hand-name="${handName}"]`).or( page.getByRole('button', { name: new RegExp(handName, 'i') }) ); await handCard.click(); // 如果有参数表单,填写参数 if (params) { for (const [key, value] of Object.entries(params)) { const input = page.locator(`[name="${key}"]`).or( page.locator(`label:has-text("${key}")`).locator('..').locator('input, textarea, select') ); if (await input.isVisible()) { await input.fill(String(value)); } } } // 触发执行 const [request] = await Promise.all([ page.waitForRequest(`**/api/hands/${handName}/trigger`), page.getByRole('button', { name: /执行|触发|run/i }).click(), ]); return request; }, }; ``` ### 4. 数据流测试示例 ```typescript // tests/e2e/specs/data-flow.spec.ts import { test, expect } from '@playwright/test'; import { networkHelpers } from '../utils/network-helpers'; import { storeInspectors } from '../fixtures/store-inspectors'; import { userActions } from '../utils/user-actions'; test.describe('聊天数据流验证', () => { test('CHAT-DF-01: 发送消息完整数据流', async ({ page }) => { // 1. 设置网络拦截 const requests = await networkHelpers.interceptAllAPI(page); // 2. Mock 流式响应 await networkHelpers.mockAPI(page, 'chat', { type: 'stream', messages: [ { delta: '你好', phase: 'start' }, { delta: '!我是', phase: 'delta' }, { delta: 'AI 助手', phase: 'delta' }, { phase: 'end' }, ], }); // 3. 发送消息 await page.goto('http://localhost:1420'); const request = await userActions.sendChatMessage(page, '测试消息'); // 4. 验证请求格式 const requestBody = request.postDataJSON(); expect(requestBody).toMatchObject({ message: '测试消息', sessionKey: expect.any(String), }); // 5. 验证 Store 状态 const chatState = await storeInspectors.getPersistedState(page, 'chat'); expect(chatState.messages).toContainEqual( expect.objectContaining({ role: 'user', content: '测试消息' }) ); // 6. 验证 UI 渲染 await expect(page.locator('[class*="message"]').filter({ hasText: '测试消息' })).toBeVisible(); await expect(page.locator('[class*="assistant"]')).toBeVisible(); }); }); ``` --- ## 验证执行 ### 运行命令 ```bash # 运行所有 E2E 测试 pnpm --filter desktop test:e2e # 运行数据流测试 pnpm --filter desktop playwright test -- --grep "数据流|data-flow" # 运行边界情况测试 pnpm --filter desktop playwright test -- --grep "边界|edge-case" # 调试模式 pnpm --filter desktop playwright test -- --headed --debug # 生成报告 pnpm --filter desktop playwright show-report ``` ### CI 集成 ```yaml # .github/workflows/frontend-verification.yml name: Frontend Verification on: [push, pull_request] jobs: e2e-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v2 - uses: actions/setup-node@v4 with: node-version: 20 cache: 'pnpm' - run: pnpm install - run: pnpm --filter desktop exec playwright install --with-deps - name: Run E2E tests run: pnpm --filter desktop test:e2e - uses: actions/upload-artifact@v4 if: always() with: name: playwright-report path: desktop/test-results/ ``` --- ## 关键文件清单 ### 需要创建的文件 | 文件路径 | 用途 | |----------|------| | `desktop/tests/e2e/specs/data-flow.spec.ts` | 数据流深度验证测试 | | `desktop/tests/e2e/specs/store-state.spec.ts` | Store 状态验证测试 | | `desktop/tests/e2e/specs/edge-cases.spec.ts` | 边界情况测试 | | `desktop/tests/e2e/fixtures/mock-gateway.ts` | Gateway Mock 工具 | | `desktop/tests/e2e/fixtures/test-data.ts` | 测试数据工厂 | | `desktop/tests/e2e/fixtures/store-inspectors.ts` | Store 检查工具 | | `desktop/tests/e2e/utils/network-helpers.ts` | 网络拦截工具 | | `desktop/tests/e2e/utils/store-assertions.ts` | Store 断言工具 | | `desktop/tests/e2e/utils/user-actions.ts` | 用户操作模拟 | ### 需要修改的文件 | 文件路径 | 修改内容 | |----------|----------| | `desktop/tests/e2e/playwright.config.ts` | 添加新测试配置 | | `desktop/src/store/*.ts` | 添加测试辅助方法(可选) | --- ## 成功标准 ### 覆盖率目标 | 层级 | 目标 | 度量方式 | |------|------|----------| | E2E 用户流程 | 100% | 10 个模块全覆盖 | | 数据流路径 | 100% | UI → Store → API → UI 验证 | | 边界情况 | 80% | 网络/数据/状态边界 | | 错误处理 | 100% | 所有错误状态 | ### 质量门槛 - [ ] 所有测试在 CI 中通过 - [ ] 正常流程无控制台错误 - [ ] 网络故障优雅处理 - [ ] 刷新后状态持久化 - [ ] 所有用户输入有验证 - [ ] 加载状态正确显示