Files
zclaw_openfang/desktop/tests/e2e/utils/user-actions.ts
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

779 lines
22 KiB
TypeScript

/**
* 用户操作模拟工具
* 封装完整的用户操作流程,确保深度验证
*
* 基于实际 UI 组件结构:
* - ChatArea: textarea 输入框, .bg-orange-500 发送按钮
* - HandsPanel: .bg-white.dark:bg-gray-800 卡片, "激活" 按钮
* - TeamList: .w-full.p-2.rounded-lg 团队项
* - SkillMarket: .border.rounded-lg 技能卡片
* - Sidebar: aside.w-64 侧边栏
*/
import { Page, Request, Response } from '@playwright/test';
const BASE_URL = 'http://localhost:1420';
/**
* 跳过引导流程
* 设置 localStorage 以跳过首次使用引导
* 必须在页面加载前调用
*/
export async function skipOnboarding(page: Page): Promise<void> {
// 使用 addInitScript 在页面加载前设置 localStorage
await page.addInitScript(() => {
// 标记引导已完成
localStorage.setItem('zclaw-onboarding-completed', 'true');
// 设置用户配置文件 (必须同时设置才能跳过引导)
localStorage.setItem('zclaw-user-profile', JSON.stringify({
userName: '测试用户',
userRole: '开发者',
completedAt: new Date().toISOString()
}));
// 设置 Gateway URL (使用 REST 模式)
localStorage.setItem('zclaw_gateway_url', 'http://127.0.0.1:50051');
localStorage.setItem('zclaw_gateway_token', '');
// 设置默认聊天 Store
localStorage.setItem('zclaw-chat-storage', JSON.stringify({
state: {
conversations: [],
currentConversationId: null,
currentAgent: {
id: 'default',
name: 'ZCLAW',
icon: '🤖',
color: '#3B82F6',
lastMessage: '',
time: ''
},
isStreaming: false,
currentModel: 'claude-sonnet-4-20250514',
sessionKey: null,
messages: []
},
version: 0
}));
});
}
/**
* 模拟 Gateway 连接状态
* 直接在页面上设置 store 状态来绕过实际连接
*/
export async function mockGatewayConnection(page: Page): Promise<void> {
await page.evaluate(() => {
try {
const stores = (window as any).__ZCLAW_STORES__;
if (stores?.gateway) {
// zustand store 的 setState 方法
const store = stores.gateway;
if (typeof store.setState === 'function') {
store.setState({
connectionState: 'connected',
gatewayVersion: '0.4.0',
error: null
});
console.log('[E2E] Gateway store state mocked');
} else {
console.warn('[E2E] Store setState not available');
}
} else {
console.warn('[E2E] __ZCLAW_STORES__.gateway not found');
}
} catch (e) {
console.warn('[E2E] Failed to mock connection:', e);
}
});
}
/**
* 等待应用就绪
* 注意:必须在 page.goto() 之前调用 skipOnboarding
*/
export async function waitForAppReady(page: Page, timeout = 30000): Promise<void> {
await page.waitForLoadState('networkidle', { timeout });
// 等待侧边栏出现
await page.waitForSelector('aside', { timeout }).catch(() => {
console.warn('Sidebar not found');
});
// 等待聊天区域出现
await page.waitForSelector('textarea', { timeout: 10000 }).catch(() => {});
// 等待状态初始化
await page.waitForTimeout(2000);
// 尝试模拟连接状态
await mockGatewayConnection(page);
// 再等待一会
await page.waitForTimeout(500);
}
/**
* 侧边栏导航项映射
*/
const NAV_ITEMS: Record<string, { text: string; key: string }> = {
: { text: '分身', key: 'clones' },
: { text: '自动化', key: 'automation' },
: { text: '技能', key: 'skills' },
: { text: '团队', key: 'team' },
: { text: '协作', key: 'swarm' },
Hands: { text: 'Hands', key: 'automation' },
: { text: '工作流', key: 'automation' },
};
/**
* 导航到指定标签页
*/
export async function navigateToTab(page: Page, tabName: string): Promise<void> {
const navItem = NAV_ITEMS[tabName];
if (!navItem) {
console.warn(`Unknown tab: ${tabName}`);
return;
}
// 查找侧边栏中的导航按钮
const navButton = page.locator('nav button').filter({
hasText: navItem.text,
}).or(
page.locator('aside button').filter({ hasText: navItem.text })
);
if (await navButton.first().isVisible()) {
await navButton.first().click();
await page.waitForTimeout(500);
}
}
/**
* 等待聊天输入框可用
*/
export async function waitForChatReady(page: Page, timeout = 30000): Promise<void> {
await page.waitForFunction(
() => {
const textarea = document.querySelector('textarea');
return textarea && !textarea.disabled;
},
{ timeout }
);
}
/**
* 用户操作集合
*/
export const userActions = {
// ============================================
// 聊天相关操作
// ============================================
/**
* 发送聊天消息(完整流程)
* @returns 请求对象,用于验证请求格式
*/
async sendChatMessage(
page: Page,
message: string,
options?: { waitForResponse?: boolean; timeout?: number }
): Promise<{ request: Request; response?: Response }> {
// 等待聊天输入框可用
await waitForChatReady(page, options?.timeout ?? 30000);
const chatInput = page.locator('textarea').first();
await chatInput.fill(message);
// 点击发送按钮 (.bg-orange-500)
const sendButton = page.locator('button.bg-orange-500').or(
page.getByRole('button', { name: '发送消息' })
).or(
page.locator('button').filter({ has: page.locator('svg') }).last()
);
// 同时等待请求和点击
const [request] = await Promise.all([
page.waitForRequest('**/api/agents/*/message**', { timeout: options?.timeout ?? 30000 }).catch(
() => page.waitForRequest('**/api/chat**', { timeout: options?.timeout ?? 30000 })
),
sendButton.first().click(),
]);
let response: Response | undefined;
if (options?.waitForResponse) {
response = await page.waitForResponse(
(r) => r.url().includes('/message') || r.url().includes('/chat'),
{ timeout: options?.timeout ?? 60000 }
);
}
return { request, response };
},
/**
* 发送消息并等待流式响应完成
*/
async sendChatMessageAndWaitForStream(page: Page, message: string): Promise<void> {
await this.sendChatMessage(page, message);
// 等待流式响应开始
await page.waitForFunction(
() => {
const stored = localStorage.getItem('zclaw-chat-storage');
if (!stored) return false;
const state = JSON.parse(stored).state;
return state.isStreaming === true;
},
{ timeout: 5000 }
).catch(() => {}); // 可能太快错过了
// 等待流式响应结束
await page.waitForFunction(
() => {
const stored = localStorage.getItem('zclaw-chat-storage');
if (!stored) return true; // 没有 store 也算完成
const state = JSON.parse(stored).state;
return state.isStreaming === false;
},
{ timeout: 60000 }
);
},
/**
* 切换模型
*/
async switchModel(page: Page, modelName: string): Promise<void> {
// 点击模型选择器 (在聊天区域底部)
const modelSelector = page.locator('.absolute.bottom-full').filter({
hasText: /model|模型/i,
}).or(
page.locator('[class*="model"]').filter({ has: page.locator('button') })
);
if (await modelSelector.isVisible()) {
await modelSelector.click();
// 选择模型
const modelOption = page.getByRole('option', { name: new RegExp(modelName, 'i') }).or(
page.locator('li').filter({ hasText: new RegExp(modelName, 'i') })
);
await modelOption.click();
await page.waitForTimeout(300);
}
},
/**
* 新建对话
*/
async newConversation(page: Page): Promise<void> {
// 侧边栏中的新对话按钮
const newChatBtn = page.locator('aside button').filter({
hasText: '新对话',
}).or(
page.getByRole('button', { name: /新对话|new/i })
);
if (await newChatBtn.first().isVisible()) {
await newChatBtn.first().click();
await page.waitForTimeout(500);
}
},
/**
* 获取连接状态
*/
async getConnectionStatus(page: Page): Promise<string> {
const statusElement = page.locator('span.text-xs').filter({
hasText: /连接|Gateway|connected/i,
});
if (await statusElement.isVisible()) {
return statusElement.textContent() || '';
}
return '';
},
// ============================================
// 分身/Agent 相关操作
// ============================================
/**
* 创建分身(完整流程)
*/
async createClone(
page: Page,
data: { name: string; role?: string; model?: string }
): Promise<{ request: Request; response: Response }> {
// 导航到分身标签
await navigateToTab(page, '分身');
// 点击创建按钮
const createBtn = page.locator('aside button').filter({
hasText: /\+|创建|new/i,
}).or(
page.getByRole('button', { name: /\+|创建|new/i })
);
await createBtn.first().click();
// 等待对话框出现
await page.waitForSelector('[role="dialog"], .fixed.inset-0', { timeout: 5000 }).catch(() => {});
const dialog = page.locator('[role="dialog"]').or(page.locator('.fixed.inset-0').last());
// 填写名称
const nameInput = dialog.locator('input').first();
await nameInput.fill(data.name);
// 填写角色(如果有)
if (data.role) {
const roleInput = dialog.locator('input').nth(1).or(
dialog.locator('textarea').first()
);
if (await roleInput.isVisible()) {
await roleInput.fill(data.role);
}
}
// 提交创建
const submitBtn = dialog.getByRole('button', { name: /确认|创建|save|submit/i }).or(
dialog.locator('button').filter({ hasText: /确认|创建|保存/ })
);
const [request, response] = await Promise.all([
page.waitForRequest('**/api/agents**', { timeout: 10000 }).catch(
() => page.waitForRequest('**/api/clones**', { timeout: 10000 })
),
page.waitForResponse('**/api/agents**', { timeout: 10000 }).catch(
() => page.waitForResponse('**/api/clones**', { timeout: 10000 })
),
submitBtn.first().click(),
]);
return { request, response };
},
/**
* 切换分身
*/
async switchClone(page: Page, cloneName: string): Promise<void> {
await navigateToTab(page, '分身');
const cloneItem = page.locator('aside button').filter({
hasText: new RegExp(cloneName, 'i'),
});
await cloneItem.first().click();
await page.waitForTimeout(500);
},
/**
* 删除分身
*/
async deleteClone(page: Page, cloneName: string): Promise<void> {
await navigateToTab(page, '分身');
const cloneItem = page.locator('aside button').filter({
hasText: new RegExp(cloneName, 'i'),
}).first();
// 悬停显示操作按钮
await cloneItem.hover();
// 查找删除按钮
const deleteBtn = cloneItem.locator('button').filter({
has: page.locator('svg'),
}).or(
cloneItem.getByRole('button', { name: /删除|delete|remove/i })
);
if (await deleteBtn.isVisible()) {
await deleteBtn.click();
// 确认删除
const confirmBtn = page.getByRole('button', { name: /确认|confirm|delete/i });
if (await confirmBtn.isVisible()) {
await confirmBtn.click();
}
}
},
// ============================================
// Hands 相关操作
// ============================================
/**
* 触发 Hand 执行(完整流程)
*/
async triggerHand(
page: Page,
handName: string,
params?: Record<string, unknown>
): Promise<{ request: Request; response?: Response }> {
// 导航到 Hands/自动化
await navigateToTab(page, 'Hands');
await page.waitForTimeout(1000);
// 找到 Hand 卡片 (.bg-white.dark:bg-gray-800)
const handCard = page.locator('.bg-white.dark\\:bg-gray-800, .bg-gray-800').filter({
hasText: new RegExp(handName, 'i'),
}).or(
page.locator('[class*="rounded-lg"]').filter({ hasText: new RegExp(handName, 'i') })
);
// 查找激活按钮
const activateBtn = handCard.getByRole('button', { name: /激活|activate|run/i }).or(
handCard.locator('button').filter({ hasText: /激活/ })
);
// 如果有参数表单,先填写参数
if (params) {
// 点击卡片展开
await handCard.click();
await page.waitForTimeout(300);
for (const [key, value] of Object.entries(params)) {
const input = page.locator(`[name="${key}"]`).or(
page.locator('label').filter({ hasText: key }).locator('..').locator('input, textarea, select')
);
if (await input.isVisible()) {
if (typeof value === 'boolean') {
if (value) {
await input.check();
} else {
await input.uncheck();
}
} else if (typeof value === 'string') {
await input.fill(value);
} else {
await input.fill(JSON.stringify(value));
}
}
}
}
// 触发执行
const [request] = await Promise.all([
page.waitForRequest(`**/api/hands/${handName}/activate**`, { timeout: 10000 }).catch(
() => page.waitForRequest(`**/api/hands/${handName}/trigger**`, { timeout: 10000 })
),
activateBtn.first().click(),
]);
return { request };
},
/**
* 查看 Hand 详情
*/
async viewHandDetails(page: Page, handName: string): Promise<void> {
await navigateToTab(page, 'Hands');
const handCard = page.locator('.bg-white.dark\\:bg-gray-800, .bg-gray-800').filter({
hasText: new RegExp(handName, 'i'),
});
// 点击详情按钮
const detailsBtn = handCard.getByRole('button', { name: /详情|details|info/i });
if (await detailsBtn.isVisible()) {
await detailsBtn.click();
await page.waitForSelector('[role="dialog"], .fixed.inset-0', { timeout: 5000 });
}
},
/**
* 审批 Hand 执行
*/
async approveHand(page: Page, approved: boolean, reason?: string): Promise<void> {
const dialog = page.locator('[role="dialog"]').filter({
hasText: /审批|approval|approve/i,
}).or(
page.locator('.fixed.inset-0').filter({ hasText: /审批|approval/i })
);
if (await dialog.isVisible()) {
if (!approved && reason) {
const reasonInput = dialog.locator('textarea').or(
dialog.locator('input[type="text"]')
);
await reasonInput.fill(reason);
}
const actionBtn = approved
? dialog.getByRole('button', { name: /批准|approve|yes|确认/i })
: dialog.getByRole('button', { name: /拒绝|reject|no/i });
await actionBtn.click();
await page.waitForTimeout(500);
}
},
// ============================================
// 工作流相关操作
// ============================================
/**
* 创建工作流
*/
async createWorkflow(
page: Page,
data: {
name: string;
description?: string;
steps: Array<{ handName: string; params?: Record<string, unknown> }>;
}
): Promise<void> {
await navigateToTab(page, '工作流');
// 点击创建按钮
const createBtn = page.getByRole('button', { name: /创建|new|\+/i }).first();
await createBtn.click();
await page.waitForTimeout(500);
// 填写名称
const nameInput = page.locator('input').first();
await nameInput.fill(data.name);
// 填写描述
if (data.description) {
const descInput = page.locator('textarea').first();
if (await descInput.isVisible()) {
await descInput.fill(data.description);
}
}
// 添加步骤
for (const step of data.steps) {
const addStepBtn = page.getByRole('button', { name: /添加步骤|add step|\+/i });
await addStepBtn.click();
// 选择 Hand
const handSelector = page.locator('select').last().or(
page.locator('[role="listbox"]').last()
);
await handSelector.click();
await page.getByText(new RegExp(step.handName, 'i')).click();
// 填写参数(如果有)
if (step.params) {
const paramsInput = page.locator('textarea').filter({
hasText: /{/,
}).or(
page.locator('input[placeholder*="JSON"]')
);
await paramsInput.fill(JSON.stringify(step.params));
}
}
// 保存
const saveBtn = page.getByRole('button', { name: /保存|save/i });
await saveBtn.click();
},
/**
* 执行工作流
*/
async executeWorkflow(page: Page, workflowId: string): Promise<void> {
await navigateToTab(page, '工作流');
const workflowItem = page.locator(`[data-workflow-id="${workflowId}"]`).or(
page.locator('[class*="workflow"]').filter({ hasText: workflowId })
);
const executeBtn = workflowItem.getByRole('button', { name: /执行|run|execute/i });
await executeBtn.click();
},
// ============================================
// 团队相关操作
// ============================================
/**
* 创建团队
*/
async createTeam(
page: Page,
data: {
name: string;
description?: string;
pattern?: 'sequential' | 'parallel' | 'pipeline';
}
): Promise<void> {
await navigateToTab(page, '团队');
// 查找创建按钮 (Plus 图标)
const createBtn = page.locator('aside button').filter({
has: page.locator('svg'),
}).or(
page.getByRole('button', { name: /\+/i })
);
await createBtn.first().click();
// 等待创建界面出现 (.absolute.inset-0.bg-black/50)
await page.waitForSelector('.absolute.inset-0, [role="dialog"]', { timeout: 5000 });
const dialog = page.locator('.absolute.inset-0, [role="dialog"]').last();
// 填写名称
const nameInput = dialog.locator('input[type="text"]').first();
await nameInput.fill(data.name);
// 选择模式
if (data.pattern) {
const patternSelector = dialog.locator('select').or(
dialog.locator('[role="listbox"]')
);
await patternSelector.click();
await page.getByText(new RegExp(data.pattern, 'i')).click();
}
// 提交
const submitBtn = dialog.getByRole('button', { name: /确认|创建|save/i });
await submitBtn.click();
},
/**
* 选择团队
*/
async selectTeam(page: Page, teamName: string): Promise<void> {
await navigateToTab(page, '团队');
const teamItem = page.locator('.w-full.p-2.rounded-lg').filter({
hasText: new RegExp(teamName, 'i'),
});
await teamItem.click();
await page.waitForTimeout(300);
},
// ============================================
// 技能市场相关操作
// ============================================
/**
* 搜索技能
*/
async searchSkill(page: Page, query: string): Promise<void> {
await navigateToTab(page, '技能');
// 搜索框 (.pl-9 表示有搜索图标)
const searchInput = page.locator('input.pl-9').or(
page.locator('input[placeholder*="搜索"]')
).or(
page.locator('input[type="search"]')
);
await searchInput.first().fill(query);
await page.waitForTimeout(500);
},
/**
* 安装技能
*/
async installSkill(page: Page, skillName: string): Promise<void> {
await navigateToTab(page, '技能');
// 技能卡片 (.border.rounded-lg)
const skillCard = page.locator('.border.rounded-lg').filter({
hasText: new RegExp(skillName, 'i'),
});
const installBtn = skillCard.getByRole('button', { name: /安装|install/i });
await installBtn.click();
await page.waitForTimeout(1000);
},
/**
* 卸载技能
*/
async uninstallSkill(page: Page, skillName: string): Promise<void> {
await navigateToTab(page, '技能');
const skillCard = page.locator('.border.rounded-lg').filter({
hasText: new RegExp(skillName, 'i'),
});
const uninstallBtn = skillCard.getByRole('button', { name: /卸载|uninstall/i });
await uninstallBtn.click();
await page.waitForTimeout(1000);
},
// ============================================
// 设置相关操作
// ============================================
/**
* 打开设置页面
*/
async openSettings(page: Page): Promise<void> {
// 底部用户栏中的设置按钮
const settingsBtn = page.locator('aside button').filter({
hasText: /设置|settings|⚙/i,
}).or(
page.locator('.p-3.border-t button')
);
await settingsBtn.first().click();
await page.waitForTimeout(500);
},
/**
* 保存设置
*/
async saveSettings(page: Page): Promise<void> {
const saveBtn = page.getByRole('button', { name: /保存|save|apply/i });
await saveBtn.click();
await page.waitForTimeout(500);
},
// ============================================
// 通用操作
// ============================================
/**
* 关闭对话框
*/
async closeModal(page: Page): Promise<void> {
const closeBtn = page.locator('[role="dialog"] button, .fixed.inset-0 button').filter({
has: page.locator('svg'),
}).or(
page.getByRole('button', { name: /关闭|close|cancel|取消/i })
);
if (await closeBtn.first().isVisible()) {
await closeBtn.first().click();
}
},
/**
* 按 Escape 键
*/
async pressEscape(page: Page): Promise<void> {
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
},
/**
* 刷新页面并等待就绪
*/
async refreshAndWait(page: Page): Promise<void> {
await page.reload();
await waitForAppReady(page);
},
/**
* 等待元素可见
*/
async waitForVisible(page: Page, selector: string, timeout = 5000): Promise<void> {
await page.waitForSelector(selector, { state: 'visible', timeout });
},
/**
* 截图
*/
async takeScreenshot(page: Page, name: string): Promise<void> {
await page.screenshot({ path: `test-results/${name}.png` });
},
};