Files
zclaw_openfang/plans/toasty-wibbling-pudding.md
iven 6f72442531 docs(guide): rewrite CLAUDE.md with ZCLAW-first perspective
Major changes:
- Shift from "OpenFang desktop client" to "independent AI Agent desktop app"
- Add decision principle: "Is this useful for ZCLAW? Does it affect ZCLAW?"
- Simplify project structure and tech stack sections
- Replace OpenClaw vs OpenFang comparison with unified backend approach
- Consolidate troubleshooting from scattered sections into organized FAQ
- Update Hands system documentation with 8 capabilities and status
- Stream
2026-03-20 19:30:09 +08:00

574 lines
22 KiB
Markdown

# 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<string, any>) {
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<string, any>) {
// 导航到 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 中通过
- [ ] 正常流程无控制台错误
- [ ] 网络故障优雅处理
- [ ] 刷新后状态持久化
- [ ] 所有用户输入有验证
- [ ] 加载状态正确显示